fix(dispatcher): 中文专家名导致多智能体协调 400 挂死

buildSpecialists 把专家名原样当 OpenAI function-calling 的 tools[].function.name,
而该字段受约束 ^[a-zA-Z0-9_-]+$。中文命名专家(中文产品里最自然的用法,如"条款专家")
会让模型直接 400:
  Invalid 'tools[0].function.name': string does not match pattern
整个协调节点挂掉,再沿 failover 链把备用模型也拖垮。

修:toolFuncName() 规范化给模型看的函数名——非法字符→下划线,清空→expert_N,
同批内撞名加序号保证唯一;展示名与执行轨迹(agent:<原名>)仍用原名不变。
模型靠 Desc(spec.Use) 判断何时调用,函数名不承载语义,故退化命名不影响派发质量。

实测(真 deepseek):修前中文名必现 tools[0].function.name 400;修后该 400 归零。
补 TestToolFuncName 覆盖:纯中文/合法ASCII/混合名/撞名唯一性。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-15 15:29:09 +08:00
parent 11bf7d2756
commit 2d5b72930a
2 changed files with 48 additions and 2 deletions
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"regexp"
"strings"
"time"
@@ -67,6 +68,26 @@ func parseSpecialists(cfg map[string]any) []specialistSpec {
return out
}
// funcNameSafe 匹配 OpenAI 兼容 API 对 tools[].function.name 的约束:^[a-zA-Z0-9_-]+$。
var funcNameSafe = regexp.MustCompile(`[^a-zA-Z0-9_-]+`)
// toolFuncName 把专家名规范化成合法的函数名(给模型看)。
// 关键:专家名常是中文("条款专家"),直接当函数名会被 OpenAI 兼容 API 400 拒绝
// Invalid 'tools[0].function.name': does not match '^[a-zA-Z0-9_-]+$'),整个多智能体协调直接挂。
// 非法字符 → 下划线;清空则退化为 expert_N;used 保证同一批内唯一。展示名/轨迹仍用原名。
// 模型靠 Desc(spec.Use) 判断何时调用它,函数名本身不承载语义,故退化命名不影响派发质量。
func toolFuncName(name string, idx int, used map[string]bool) string {
s := strings.Trim(funcNameSafe.ReplaceAllString(name, "_"), "_-")
if s == "" {
s = fmt.Sprintf("expert_%d", idx+1)
}
if used[s] {
s = fmt.Sprintf("%s_%d", s, idx+1)
}
used[s] = true
return s
}
// specialistTool 把一个专家(react.Agent 或 ChatModel)包成 Eino InvokableToolagent-as-tool):
// 入参 brief = lead 写给它的定制简报;返回专家的精炼结论。每次派发落一条 agent 轨迹。
type specialistTool struct {
@@ -130,7 +151,8 @@ func (o *Orchestrator) buildSpecialists(ctx context.Context, specs []specialistS
}
}
var out []tool.BaseTool
for _, spec := range specs {
usedFuncNames := map[string]bool{}
for i, spec := range specs {
sys := firstNonEmpty(spec.System, defaultAgentSystem) + specialistCondensedSuffix
run := o.specialistRunner(ctx, spec, sys, byName)
if run == nil {
@@ -140,7 +162,7 @@ func (o *Orchestrator) buildSpecialists(ctx context.Context, specs []specialistS
out = append(out, &specialistTool{
name: spec.Name, tr: tr, run: run, timeout: specialistTimeout,
info: &schema.ToolInfo{
Name: spec.Name,
Name: toolFuncName(spec.Name, i, usedFuncNames), // 给模型的函数名须 ASCII;展示/轨迹仍用 spec.Name
Desc: firstNonEmpty(spec.Use, "专家 "+spec.Name),
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
"brief": {Type: schema.String, Desc: "给该专家的明确简报:子任务、期望输出、边界约束", Required: true},