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

初学 Agent 时容易把注意力放在工具的定义上:schema 怎么写、参数怎么传、返回值怎么拼。这些确实重要,但把它们串起来跑起来的,是一个三十行上下的循环。模型每轮给出一个决定,程序执行这个决定,把结果交回去,再问一次。Agent 的骨架就是这样。

五步转一圈

下面这段循环依赖前文定义好的 client 与 registry,registry.to_schemas() 返回工具定义,registry.invoke() 按名字执行工具。

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
27
28
29
30
31
32
33
34
35
def run_agent(question: str, max_iters: int = 6) -> str:
messages = [
{"role": "system", "content": "你是助手。需要时调用工具。"},
{"role": "user", "content": question},
]
for _ in range(max_iters):
msg = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=registry.to_schemas(),
tool_choice="auto",
temperature=0,
).choices[0].message

if not msg.tool_calls:
return msg.content or ""

messages.append({
"role": "assistant",
"content": msg.content,
"tool_calls": [
{"id": tc.id, "type": "function",
"function": {"name": tc.function.name,
"arguments": tc.function.arguments}}
for tc in msg.tool_calls
],
})
for tc in msg.tool_calls:
args = json.loads(tc.function.arguments)
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": registry.invoke(tc.function.name, args),
})
return "达到最大轮次仍无最终回答"

循环的动作只有五步:调模型、读 tool_calls、执行工具、把结果追加回 messages、进入下一轮。全程没有一步要求模型”输出 JSON 再解析”。

一次响应里返回多个 tool_calls 时,循环逐个执行、逐个追加,再统一进入下一轮。并行发起省下的是一轮往返,执行本身仍然是串行的,所以顺序和耗时都由代码控制。

退出条件只有一个,就是 tool_calls 为空。这个判断不能换成检查 content 里有没有”完成”或者”答案”。协议保证模型不再调用工具时该字段为空,自然语言的措辞没有这种保证,模型完全可能在一句寒暄里带上”完成”两个字。

每轮都要重传全部历史

模型不保存会话。第一轮请求里的 messages 是 [system, user];模型要求调用工具后,列表变成 [system, user, assistant(tool_calls), tool];第二轮再把整个列表发过去。到第十轮,前面十轮的每一条都还在请求体里。

这份历史只增不减,是上下文膨胀的直接来源。对话越长,每轮 token 越多,成本和延迟一起上涨,直到撞上窗口上限。压缩、截断、记忆抽取这些手段,处理的都是这个列表。

温度也在同一个请求里定下来。调试阶段把它压到 0,同一个问题才能跑出可复现的路径;否则两次结果不同,很难判断差别来自代码还是采样。

两条消息,漏一条就出问题

assistant 消息必须带着 tool_calls 原样追加。漏掉它,下一轮模型看不到自己刚刚决定调什么,于是重新决策:要么重复同一个调用,要么换个工具从头试。表现出来就是原地打转,一轮一轮没有进展。

tool 消息必须带 tool_call_id,与 assistant.tool_calls 中的 id 一一对应。模型靠这个字段判断哪条结果属于哪个调用。一次返回多个工具调用时,如果靠顺序去对,只要执行顺序有变就会错位,模型拿到的是张冠李戴的结果。

刹车

循环需要一个上限。没有上限,下面三种情形都会让它一直转下去:模型反复调用一个持续失败的工具;工具抛出异常而循环没有接住,整个程序退出;模型意识不到信息已经足够,继续发起调用。

刹车 实现方式
最大轮次 for 循环加上限,课程示例取 6
工具异常兜底 try/except 把错误信息转成字符串,作为 tool 结果塞回去
重复调用检测 记录工具名与参数,连续相同达到阈值就停止

异常兜底值得展开一句。工具报错时把错误文本交还给模型,模型往往能读到原因并改参数,比如路径写错就换一个路径。把异常抛到循环外面,模型连补救的机会都没有。

跟”让模型输出 JSON”比

在 tool_calls 出现之前,让模型调用工具的常见做法是在 system prompt 里约定输出格式,再用正则或 JSON 解析器读取。课程里演示过这个写法,也演示了它的失败方式。

维度 prompt 约定 JSON tool_calls
格式来源 system prompt 里的约定 协议字段
解析失败 模型多说一句就可能失败 极少
并行调用 自己写解析 协议原生支持
生态 各自为政 OpenAI、Anthropic、Google 通用

课程示例里那四个任务正好覆盖四种情况:问时间、算 17 × 23、抓取网页字符长度再除以 7、问 2099 年世界杯冠军。前两个一轮结束,第三个要串两个工具、跑两轮,第四个没有合适工具。第四个任务最能看出循环的质量:理想表现是承认不知道,常见失败是硬挑一个工具调用,把无关结果拼成答案。

这对搭 Agent 意味着什么

循环里刻意没有放的东西同样值得列出来:上下文压缩、跨会话记忆、多 Agent 协作、终端界面。它们各自单独成章,全塞进来只会让三十行变成三百行,而骨架反而看不清。

把循环写对,门槛不在代码量,而在几个容易被省略的细节:退出只看 tool_calls、assistant 与 tool 两条消息都要追加、上限和异常兜底不能省。这些细节决定了 Agent 是稳定地跑完,还是跑几轮后开始重复自己。

真正的复杂度在循环之外,工具集怎么组织、上下文怎么压缩、过程怎么展示,都要在骨架之上继续搭。

循环定型之后,下一个问题在协议边界上:工具从哪来、怎么打包、怎么把私有知识喂进去。下一篇看 Tool Calling 的三个扩展面。