小模型工具调用的五个坑与工程解法

#Agent实战

小模型工具调用的五个坑与工程解法

把 1.5B 级别的模型接进真实系统,最大的落差不在"能不能调工具",而在它调得对不对、结果可不可信。 这篇是我把站内助手跑上线后踩到的五个坑,每个都附上工程解法——注意是工程解法,不是"换个更大的模型"。

坑一:模型会复述错确定性结果

我让它算 hello 的 md5。工具调用完全正确:

{"tool":"md5","args":{"input":"hello"}}
→ 工具返回 5d41402abc4b2a76b9719d911017c592

然后让模型"基于工具结果回答",它输出:

hello 的 md5 结果是:9e10...(编的)

它没有抄错,它是重新算了一个错的。 这类工具(哈希、编码、UUID、时间戳)的结果是确定性的, 让模型"转述"纯属多余,而且必然引入幻觉。

解法:确定性工具短路——结果由代码直接返回,不进入模型成文环节。

// 工具执行成功且属于确定性工具时,记下来
if isExecTool(act.Tool) {
    deterministic = &res
}

// 成文前判断:只有这一条观察、且来自确定性工具 → 直接输出,跳过模型
if deterministic != nil && len(observations) == 1 {
    emit(Event{Type: "delta", Text: deterministic.Text})
    emit(Event{Type: "done", Text: deterministic.Text, Refs: refs})
    return nil
}

顺带的好处:这类问答从 100 秒降到 12 秒。

坑二:工具名混淆——模型把"参数"当"工具"

我最初只注册了一个 tool_run(tool, input) 通用入口,想让模型这样用:

{"tool":"tool_run","args":{"tool":"sha1","input":"hello"}}

实测它输出的是:

{"tool":"sha1","args":{"input":"hello"}}

然后报"工具 sha1 不存在"。小模型分不清"工具名"和"工具的参数字段",二段式调用对它太难。

解法:把可执行工具注册成一等工具。md5、sha1、base64_encode 各自是一个工具, 参数统一为 input。模型少一层嵌套,成功率立刻上来:

for _, name := range execTools {   // md5, sha1, base64_encode, uuid, ...
    r.add(&Tool{Name: name, Params: "input(要处理的字符串)", Run: execRunner(name)})
}

tool_run 仍然保留(有人会用),但提示词里明确说"这些名字可以直接当 tool 用"。

坑三:同一个工具被反复调用

决策轮里模型常常:

第 1 轮 {"tool":"kb_toc","args":{}}     ← 已经拿到目录
第 2 轮 {"tool":"kb_toc","args":{}}     ← 又来一次(结果一模一样)

它不是在试探,而是忘了自己调过。白烧一轮时间(在这台机器上就是 20 秒)。

解法:按「工具名 + 参数」去重,重复调用不回喂工具,只提醒它收尾:

callKey := act.Tool + "|" + fmt.Sprint(act.Args)
if seenCalls[callKey] {
    msgs = append(msgs, Message{Role: "user",
        Content: "这个工具刚调用过,结果已在上文。请直接输出 {\"final\":\"...\"}。"})
    continue
}
seenCalls[callKey] = true

坑四:协议漂移——final 里塞 JSON

让它用一句话回答,它给的是:

{"final":"{\"error\":\"工具 'sha1' 不存在\"}"}

final 的值是另一个 JSON。如果你直接把这个值当答案返回,用户看到的就是一串转义字符。

解法:final 必须是自然语言,JSON 形态一律丢弃:

func cleanFinal(s string) string {
    s = strings.TrimSpace(s)
    if s == "" || strings.HasPrefix(s, "{") || strings.HasPrefix(s, "[") {
        return ""   // 协议错误,走兜底
    }
    return s
}

丢弃之后不能直接报错,要落到兜底链:有资料就成文,没资料就降级成"检索知识库再回答"。

坑五:慢模型把"超时"变成新的失败点

本地生成 2 tok/s,一次回答 100 秒起步。上线后发现两类问题:

  1. 网关超时:nginx 的 /api 默认 proxy_read_timeout 60s,决策轮 + 成文之间的空档超过 60 秒, 连接被掐断,前端只看到"网络错误"。
  2. 没有缓冲控制:nginx 默认 proxy_buffering on,SSE 会被攒着一次性吐出,"流式"退化成"等 100 秒"。

解法是在 nginx 层为这个接口单独开一段,而不是调全局:

location ^~ /api/v1/agent/ {
    proxy_pass http://127.0.0.1:8080;
    proxy_read_timeout  300s;
    proxy_send_timeout  300s;
    proxy_buffering     off;   # SSE 必须关缓冲
    gzip off;                  # 否则会攒 gzip 块
}

后端再补一个响应头,双保险:

c.Header("X-Accel-Buffering", "no")

还有两个配套动作:决策轮之间要发心跳(哪怕是 meta 事件也要先吐出去), 前端要有"思考中"状态(用户等 30 秒没反馈就会刷新页面,等于前功尽弃)。

一条经验总结

这五个坑的共同点是:模型的行为不可预期,所以工程上必须假设它会犯错。 具体到落地,就是三件事:

层次做法
结果层确定性结果不让模型转述,代码直接给
协议层结构校验 + 丢弃非法输出 + 不报错而是重试/兜底
系统层超时、缓冲、配额、并发闸门都在网关和 handler 层显式配置

把这三层做扎实,1.5B 的小模型也能做出"可用"的助手——它会慢,会偶尔答得笨, 但不会编造哈希、不会因为一个格式错误就整体崩掉。

小结

小模型落地不是"模型能力"问题,是边界设计问题:哪些判断交给模型(选工具、组织语言), 哪些必须由代码接管(确定性计算、协议校验、超时与配额)。边界划清楚,2 tok/s 的模型也能用; 划不清楚,再大的模型也会在细节上翻车。

笔记加载中…