From 2d5b72930a09f54972ad13a1da2e9bb2a36744eb Mon Sep 17 00:00:00 2001 From: Blizzard Date: Wed, 15 Jul 2026 15:29:09 +0800 Subject: [PATCH] =?UTF-8?q?fix(dispatcher):=20=E4=B8=AD=E6=96=87=E4=B8=93?= =?UTF-8?q?=E5=AE=B6=E5=90=8D=E5=AF=BC=E8=87=B4=E5=A4=9A=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E4=BD=93=E5=8D=8F=E8=B0=83=20400=20=E6=8C=82=E6=AD=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../internal/eino/coordinator.go | 26 +++++++++++++++++-- .../internal/eino/coordinator_test.go | 24 +++++++++++++++++ 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/sundynix-dispatcher/internal/eino/coordinator.go b/sundynix-dispatcher/internal/eino/coordinator.go index 8a12e75..f89396f 100644 --- a/sundynix-dispatcher/internal/eino/coordinator.go +++ b/sundynix-dispatcher/internal/eino/coordinator.go @@ -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 InvokableTool(agent-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}, diff --git a/sundynix-dispatcher/internal/eino/coordinator_test.go b/sundynix-dispatcher/internal/eino/coordinator_test.go index e3aa664..46697a7 100644 --- a/sundynix-dispatcher/internal/eino/coordinator_test.go +++ b/sundynix-dispatcher/internal/eino/coordinator_test.go @@ -89,3 +89,27 @@ func TestRunCoordinatorDegrade(t *testing.T) { t.Fatalf("ToolCallingModel 缺失应降级 runAgent 出答案,got %q", b.answer) } } + +// 专家名常是中文,直接当 LLM 函数名会被 OpenAI 兼容 API 400 拒绝(^[a-zA-Z0-9_-]+$), +// 整个多智能体协调会挂。toolFuncName 负责规范化 + 保证唯一。 +func TestToolFuncName(t *testing.T) { + used := map[string]bool{} + if got := toolFuncName("条款专家", 0, used); got != "expert_1" { + t.Errorf("纯中文名应退化为 expert_1,got %q", got) + } + if got := toolFuncName("clause_expert", 1, used); got != "clause_expert" { + t.Errorf("合法 ASCII 名应原样保留,got %q", got) + } + if got := toolFuncName("法务expert", 2, used); got != "expert" { + t.Errorf("混合名应剥出 ASCII 部分,got %q", got) + } + // 与上一个 "expert" 撞名 → 加序号保证唯一 + if got := toolFuncName("风控expert", 3, used); got != "expert_4" { + t.Errorf("撞名应加序号,got %q", got) + } + for _, n := range []string{"expert_1", "clause_expert", "expert", "expert_4"} { + if !used[n] { + t.Errorf("%q 应已登记进 used", n) + } + } +}