小模型工具调用的五个坑与工程解法
把 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 秒起步。上线后发现两类问题:
- 网关超时:nginx 的
/api默认proxy_read_timeout 60s,决策轮 + 成文之间的空档超过 60 秒, 连接被掐断,前端只看到"网络错误"。 - 没有缓冲控制: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 的模型也能用; 划不清楚,再大的模型也会在细节上翻车。