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

让模型做一件它做不到的事:抓取某个网页,报出内容有多少字节,再把这个数字除以 7。只调用一次模型的程序,回答通常落在两种结果里。一种老实承认访问不了网络,请对方把内容贴过来。另一种给出一个很像样的数字,比如 1256 字节,除以 7 约等于 179.43,而这个字节数是编的。

同一个模型,外面加一个循环和两个工具,这道题就能走完:抓网页拿回真实的 1256 字节,调计算器做除法,再把两步结果组织成一句回答。模型没有变强,结构变了。

一条直线与一个循环

Chatbot 的结构是一条直线。消息传进去,回答取回来,函数返回。

1
2
3
4
5
resp = client.chat.completions.create(
model=MODEL,
messages=[{"role": "user", "content": question}],
)
print(resp.choices[0].message.content)

Agent 在这条直线上套了一个循环,退出条件只有一个:模型某次返回里没有 tool_calls。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
messages = [{"role": "user", "content": question}]

for turn in range(max_turns):
resp = client.chat.completions.create(
model=MODEL,
messages=messages,
tools=tools,
tool_choice="auto",
)
msg = resp.choices[0].message

if not msg.tool_calls:
print(msg.content)
break

messages.append(msg)
for call in msg.tool_calls:
args = json.loads(call.function.arguments)
result = run_tool(call.function.name, args)
messages.append({"role": "tool", "tool_call_id": call.id, "content": result})

循环体里没有复杂逻辑,只有三件事:调一次模型,看它要不要工具,把工具的执行结果写回消息列表。能力来自重复:模型每一次被调用,都能看到上一次执行的结果,于是可以接着往下走。

循环把 messages 变成工作记录

Chatbot 的 messages 在开头构造一次,之后不再变化。Agent 的 messages 每轮都在变长,模型的工具请求和工具的执行结果都会追加进去。第二轮调用时,模型看到的已经不只是一句问题,还有”我上一步决定抓哪个网址””那个网址返回了多少字节”这些中间状态。

这也是 Agent 的上下文开销比 Chatbot 高的原因。一个任务调用三五次模型是常态,每次都要重新传输整段历史,越往后越贵。

那道题的实际走向大致如下:

1
2
3
4
5
第 1 轮:模型要求调 fetch_url("https://example.com")
工具返回:1256 字节
第 2 轮:模型要求调 calculator("1256 / 7")
工具返回:179.42857142857142
第 3 轮:模型给出最终回答,不再要求工具

每一轮都是同一次模型调用,只是输入的 messages 不同。

控制权在外层程序手里

一种常见误解是换一个更强的模型,它就能自己把活干完。模型本身没有”继续做下一步”的机制。它每次被调用只做一件事:根据当前的 messages 给出下一条消息,内容是一段文字,或者一组工具调用请求。要不要再来一轮、工具失败后怎么处理、什么条件下停下来,全部由外层程序决定。

业界给这层程序起过一个形象的名字:Harness,马具。模型是马,Harness 是缰绳和鞍。马再快,没有鞍具也载不了人。

1
2
3
4
Chatbot:  用户 -> [LLM] -> 用户
Agent: 用户 -> [程序 { LLM, 工具, 循环 }] -> 用户
↑
这一层是手写的

这解释了另一件事:Agent 的很多问题不在模型侧。同一套提示词,换一批工具、改一下轮次上限、调整错误信息的回填格式,行为会明显不同。后面会讲到的 ReAct、Plan-and-Solve、Reflection,都是在循环体内部做文章,循环本身不动。

多轮对话也跨不过这条分界。带历史记录的 Chatbot 会把之前的对话一起传给模型,看起来消息很多,但每一轮仍然是一次调用、一次回答:用户说一句,程序调一次模型,返回,结束。驱动循环的是人。Agent 的循环在程序内部,模型和工具可以来回走好几趟,中间不需要人插话。程序会不会自己决定再调一次模型,才是判断标准,消息条数不算。

2023 年那条分界线

用提示词约定格式、再从文本里用正则抠出工具名,这条路 2022 年的 ReAct 论文就走过。它的脆弱点在于没有结构化字段:模型多写一句解释、把 Action 换行、参数里带个逗号,解析就会错位。工程上只能靠大量容错分支兜住。

2023 年 6 月,OpenAI 推出 Function Calling,把”要调哪个工具、传什么参数”从自然语言搬进了 assistant 消息里的 tool_calls 字段。解析器不再需要猜测模型的意思。写 Agent 的门槛从”能写几百行容错解析”降到”能写一个循环”,Cursor、Claude Code 这类工具也是在这之后集中出现的。

对搭建 Agent 意味着什么

循环必须有边界。模型可能连续要求调用工具而不给出最终回答,也可能反复调同一个失败的工具。轮次上限、未知工具的兜底、工具异常的捕获,这些不是可选项,缺一个就可能让程序卡死或跑飞。

工具的描述和结果的格式,直接决定任务能不能完成。模型只能看到两样东西:schema 里的 description,以及回填进 messages 的结果字符串。描述含糊,模型就选错工具;结果格式混乱,模型就误判下一步。

下一节把这份消息结构逐字段拆开:tool_calls 里到底有什么,tool 消息凭什么对得上对应的调用。