Files
Blizzard 2d5b72930a 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>
2026-07-15 17:41:21 +08:00

116 lines
4.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package eino
import (
"context"
"fmt"
"strings"
"testing"
"github.com/cloudwego/eino/schema"
"github.com/sundynix/sundynix-dispatcher/internal/dsl"
"github.com/sundynix/sundynix-dispatcher/internal/harness"
"github.com/sundynix/sundynix-dispatcher/internal/llm"
)
// TestParseSpecialists 钉死节点 config 的 agents 列表解析(含 tools 子集、无名跳过)。
func TestParseSpecialists(t *testing.T) {
cfg := map[string]any{"agents": []any{
map[string]any{"name": "legal", "use": "法律条款", "system": "你是律师", "tools": []any{"wiki_search", "kb_search"}},
map[string]any{"name": "finance", "use": "财务测算"},
map[string]any{"use": "无名字应被跳过"},
}}
specs := parseSpecialists(cfg)
if len(specs) != 2 {
t.Fatalf("应解析出 2 个专家(无名跳过),got %d", len(specs))
}
if specs[0].Name != "legal" || specs[0].Use != "法律条款" || len(specs[0].Tools) != 2 {
t.Fatalf("legal 解析有误: %+v", specs[0])
}
if specs[1].Name != "finance" || len(specs[1].Tools) != 0 {
t.Fatalf("finance 解析有误: %+v", specs[1])
}
}
// TestSpecialistToolInvoke 钉死 agent-as-tool 包装:lead 写的 brief 透传给专家、结论回传、
// 空 brief 兜底、专家失败作为"观察"返回而非中断协调。
func TestSpecialistToolInvoke(t *testing.T) {
var gotBrief string
st := &specialistTool{
name: "legal", tr: &execTracer{},
info: &schema.ToolInfo{Name: "legal"},
run: func(_ context.Context, brief string) (string, error) { gotBrief = brief; return "结论:条款合规", nil },
}
out, err := st.InvokableRun(context.Background(), `{"brief":"审查合同第3条违约金"}`)
if err != nil {
t.Fatal(err)
}
if gotBrief != "审查合同第3条违约金" {
t.Fatalf("lead 简报应透传给专家,got %q", gotBrief)
}
if out != "结论:条款合规" {
t.Fatalf("专家结论应回传,got %q", out)
}
// 空 brief → 兜底文案(不把空串丢给专家)。
st.run = func(_ context.Context, brief string) (string, error) { gotBrief = brief; return "ok", nil }
_, _ = st.InvokableRun(context.Background(), `{}`)
if !strings.Contains(gotBrief, "未给简报") {
t.Fatalf("空 brief 应兜底,got %q", gotBrief)
}
// 专家失败 → 返回失败观察,err 为 nil(不中断 orchestrator 的 ReAct 循环)。
st.run = func(_ context.Context, _ string) (string, error) { return "", fmt.Errorf("boom") }
out, err = st.InvokableRun(context.Background(), `{"brief":"x"}`)
if err != nil {
t.Fatalf("专家失败不应上抛 error(应作观察返回): %v", err)
}
if !strings.Contains(out, "执行失败") {
t.Fatalf("应返回失败观察,got %q", out)
}
}
// TestRunCoordinatorDegrade 钉死降级:模型不支持函数调用(ToolCallingModel=nil)→ 退回普通对话出稿,
// 不因"多智能体"而卡死或空答。
func TestRunCoordinatorDegrade(t *testing.T) {
fs := &fakeSink{}
o := &Orchestrator{
pool: &fakeLLM{ready: true, stream: func([]llm.ChatMessage) string { return "降级直答" }},
breaker: harness.NewCircuitBreaker(),
sink: fs,
}
b := &board{query: "你好"}
n := dsl.Node{ID: "c", Kind: "coordinator", Config: map[string]any{
"agents": []any{map[string]any{"name": "x", "use": "y"}},
}}
o.runCoordinator(context.Background(), "t_co", b, "", n, &execTracer{}, "coordinator:c")
if !strings.Contains(b.answer, "降级直答") {
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_1got %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)
}
}
}