拼 prompt 的 JSON 靠不住
知识库问答服务最早的工具调用,是把工具列表拼进 system prompt,让模型自己输出 JSON。结果经常漏字段、枚举值写错,甚至回一段自然语言而不是 JSON。
后来 OpenAI 兼容协议支持了 Function Calling,我们切到原生 tool_calls,日子好过了一点。但生产环境又冒出新问题:模型选错工具、参数不合法、工具执行超时、多轮调用陷入死循环。
这篇讲我们怎么把它从"能跑通"做成"生产可用"。
我们抽象了一个 Tool 接口,所有工具实现它,框架负责注册、schema 生成、参数校验、执行和结果回填:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| type Tool interface {
Name() string
Description() string
Schema() *jsonschema.Schema // 入参 JSON Schema
Invoke(ctx context.Context, args json.RawMessage) (any, error)
}
type Registry struct {
tools map[string]Tool
}
func (r *Registry) Definitions() []openai.Tool {
defs := make([]openai.Tool, 0, len(r.tools))
for _, t := range r.tools {
defs = append(defs, openai.Tool{
Type: "function",
Function: openai.FunctionDefinition{
Name: t.Name(),
Description: t.Description(),
Parameters: t.Schema(),
},
})
}
return defs
}
|
每个工具自己声明入参 struct,用 tag 生成 JSON Schema,避免手写 schema 跟代码脱节:
1
2
3
4
5
6
7
8
9
| type SearchDatasetArgs struct {
DatasetID string `json:"dataset_id" jsonschema:"required,description=数据集ID"`
Query string `json:"query" jsonschema:"required,description=检索关键词"`
TopK int `json:"top_k" jsonschema:"description=返回条数,default=5"`
}
func (s *SearchDatasetTool) Schema() *jsonschema.Schema {
return jsonschema.Reflect(&SearchDatasetArgs{})
}
|
执行循环才是核心
一次用户请求内维护一个 maxIterations(默认 5),每次拿到模型响应后:
- 如果没有
tool_calls,把内容作为最终回答返回; - 如果有,逐个执行工具,把结果以
role=tool 消息追加到对话历史; - 再次请求模型,直到模型不再调用工具或达到迭代上限。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| for iter := 0; iter < maxIter; iter++ {
resp, err := s.llm.Chat(ctx, msgs, tools)
if err != nil {
return nil, err
}
if len(resp.ToolCalls) == 0 {
return resp.Content, nil
}
for _, call := range resp.ToolCalls {
tool, ok := reg.Get(call.Function.Name)
if !ok {
msgs = append(msgs, toolErrMsg(call.ID, "tool not found"))
continue
}
if err := validateArgs(tool.Schema(), call.Function.Arguments); err != nil {
msgs = append(msgs, toolErrMsg(call.ID, err.Error()))
continue
}
result, err := invokeWithTimeout(ctx, tool, call.Function.Arguments)
if err != nil {
msgs = append(msgs, toolErrMsg(call.ID, err.Error()))
continue
}
msgs = append(msgs, openai.ToolMessage(call.ID, result))
}
}
|
五个坑
参数校验必须做。就算有 schema,模型照样会输出类型错误或枚举外的值。我们在执行前用 JSON Schema 校验一遍,失败不直接报错给用户,而是把错误信息作为 tool result 回给模型,让它自我修正。通常一两轮就能改对。
工具数量和描述质量要严控。工具一多,模型选错的概率直线上升。我们的经验是单次对话注入的工具不超过 15 个;工具名用动宾结构(search_dataset、export_order),description 写清"什么时候用、什么时候不要用"。这比罗列参数说明更能降低误调用。
执行要隔离,超时要兜底。工具可能查数据库、调第三方接口,不能让一个慢工具拖垮整个会话。每个工具执行都包一层带超时的 context,用带缓冲的 channel 接结果,超时就返回错误,让模型自己决定要不要重试。
死循环要防。除了 maxIterations,我们还记录每次调用的"工具名+参数"hash,连续两次完全相同的调用直接中断,免得模型用同样的参数反复撞同一个错误。
结果要裁剪。有些工具(SQL 查询、文献检索)返回的数据量很大,直接塞回上下文会爆 token。我们在工具层统一做裁剪和结构化摘要,只给模型相关的前 N 条和总命中数,完整结果通过引用 ID 让前端按需拉取。
后来
Function Calling 的工程化,难点是围绕模型的不确定性做防御:schema 校验、执行隔离、迭代上限、结果裁剪。知识库问答服务用统一的 Tool 接口和执行循环,把这些横切逻辑收敛到框架里。新增业务工具只要实现接口、把描述写好,就能安全地交给模型调用。
封面图:M McBey / Flickr · CC BY 2.0