本文整理自 CNB 上的开源课程 《Vibe Agent:从零搭建 Coding Agent》。课程讲义、示例代码与练习仓库均在 CNB 上公开,文中示例出自该仓库对应章节。

问模型北京今天天气怎么样,它不会真的去查。它做的事情是:判断这个问题该用 get_weather,把工具名和参数放进一个结构化字段返回给程序。程序执行工具拿到”北京 晴 25°C”,把结果写回消息列表,再调一次模型。整个过程在 messages 里留下四条记录。这套结构就是 Function Calling 协议。

工具说明书由 schema 拼成

每个工具上线前要写一份机器可读的说明,OpenAI 的格式是一个固定嵌套的字典。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
TOOLS = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询指定城市的当前天气",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市名,例如 '北京'"}
},
"required": ["city"],
},
},
}]

模型从这里读出三件事:工具叫 get_weather,它的用途是查城市当前天气,调用时必须传一个字符串 city。

函数体的代码模型看不到,它只有这份 schema。所以 description 的写法直接决定成败:写成”获取数据”,模型不知道什么时候该用;写成”查询指定城市的当前天气”,遇到天气问题它才会想到这个工具,也才不会把它用在”查北京人口”上。这份描述是给模型的调用说明,不是给人看的注释。

schema 通过 tools 参数传进去,同时用 tool_choice 控制这一次允不允许调用。

1
2
3
4
5
6
resp = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=TOOLS,
tool_choice="auto",
)
取值 含义
"auto"(默认) 模型自己决定调不调
"none" 这次不允许调工具
"required" 这次至少要调一个
{"type": "function", "function": {"name": "get_weather"}} 这次必须调指定工具

日常场景用 auto 就够。required 适合流程里必须落地一次查询的步骤,none 适合要模型只做文字总结的那一步。

四条消息的往返

一次完整的工具调用,messages 里至少有四条消息,角色的种类也从三种变成四种。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
messages = [
{"role": "system", "content": "你可以用 get_weather 查询天气。"},
{"role": "user", "content": "北京今天天气怎么样?"},
{
"role": "assistant",
"content": None,
"tool_calls": [{
"id": "call_001",
"type": "function",
"function": {"name": "get_weather", "arguments": "{\"city\": \"北京\"}"},
}],
},
{"role": "tool", "tool_call_id": "call_001", "content": "北京 晴 25°C"},
]

system 消息不参与调用与结果的配对,它只交代可用工具和角色设定。tool 是新增的第四种角色,它只承载工具的执行结果,靠 tool_call_id 指回上一条 assistant 里的某个调用。arguments 是 JSON 字符串,不是字典,执行前要 json.loads。

把这四条交给模型再调一次,它会生成第五条 assistant 消息,那是一段自然语言:”北京今天晴,25°C,适合短袖加薄外套。”到这里一次工具调用才完整结束。

tool_calls 是列表,不是单个对象,因为模型可以一次要求并行调用。问”北京、上海、深圳三地天气”,它可能一次返回三个 get_weather 请求。

1
2
3
4
5
async def run_tools(msg, messages):
cities = [json.loads(c.function.arguments)["city"] for c in msg.tool_calls]
results = await asyncio.gather(*[fetch_weather(city) for city in cities])
for call, result in zip(msg.tool_calls, results):
messages.append({"role": "tool", "tool_call_id": call.id, "content": result})

就算一开始只处理单个调用,写循环时也应当把 tool_calls 当列表遍历。后面要加并行,只需要替换执行那几行,消息结构不用改。

消息回填容易出错的地方

模型决定调工具时,assistant 的 content 常常是 None 或空字符串。真正的指令在 tool_calls 里。打印 content 看到 None 就以为请求失败,是常见的误判。

带 tool_calls 的那条 assistant 消息必须原样追加回 messages。只追加 tool 消息而漏掉它,模型下一轮看不到自己刚才决定要调什么,往往会把同一个调用再发一次,循环空转。

tool_call_id 必须和 tool_calls 里的 id 严格对应。一次返回多个调用时,结果与请求全靠这个 id 配对,对错了位置,模型会把字节数当成天气。

从正则解析到结构化字段

2022 年 ReAct 论文提出边想边调工具时,没有这种字段。做法是在 system 里规定模型按 Thought: ... / Action: get_weather(city="北京") 的格式输出,再用正则从文本里抠出工具名和参数。

模型的输出 解析为什么会坏
Action: get_weather(city="北京")\n这是因为... 多出来的解释被贪婪匹配吞进参数
Action: search(query="hello, "world"") 嵌套引号让参数切分错位
Action: get_weather(city="北 流式输出未收完就解析,参数残缺

每一条都要写专门的容错分支,还要应付模型把 Action 写成 action、Tool、Use 的各种变体。流式输出下 tool_calls 也是分片到达的,参数要按 index 累积到结束才是完整 JSON。2023 年 6 月之后,工具请求变成严格的 JSON 字段,解析不再靠猜格式,写 Agent 的主要工作量从”防御模型的措辞”转回到”设计工具和循环”。

对搭建 Agent 意味着什么

协议的稳定带来一个直接结果:messages 列表就是 Agent 的全部状态。模型不记得上一轮,程序每轮把完整列表传回去,因此上下文长度、消息顺序、字段完整性三样东西决定了 Agent 能不能连续工作。

参数是合法 JSON,不代表参数有意义。{"city": "北金"} 语法正确,工具却查不到。工具层仍要做校验,并且把失败信息转成字符串回填给模型,让它自己换参数重试。协议只保证格式,不保证语义。

下一节处理另一个重复劳动:每接入一个工具都要手写三十行 schema,这件事完全可以由函数签名自动生成。