对照过 DeepSeek Harness、pi、openai-agents-python、AgentScope、OpenClaw 之后,结论比预想的更薄:
现代 Agent Loop 不是在教模型怎么思考。模型是无状态补全器,只会吐一段字,或吐一组结构化工具调用。循环是它没有的那一层运行时——手和脚。
本文只聚焦 Agent Loop 自身:协议到底长什么样,各家是不是都在认 tool_calls,以及你真要手写时该写什么、不该写什么。
下文假设工具已经有了可供模型读取的 schema,重点放在模型输出之后,循环如何识别、执行、回填和停机。
先把两层拆开
手写 Loop 时最容易混在一起的,是这两件事:
| 层 | 谁负责 | 你要不要「规划思考」 |
|---|---|---|
| 模型要不要调工具、调哪个、参数是什么 | 模型 + 各家 Native Function Calling | 不用。 把工具目录塞进请求,它自己填结构化字段 |
| 谁执行、结果怎么回去、何时停、历史怎么拼 | 你的 while | 必须写。 模型做不了 |
Chat API 是一次函数调用,不是一个活着的进程:
输入:messages + tools 菜单
输出:一段 content,和 / 或一组 tool call
然后连接断开
它没有文件系统,不能自己 ls,不能自己再请求自己,也不记得上一轮——除非你把历史再寄回去。所以「下一步、执行工具、再想一次」不可能发生在模型内部。
老式 ReAct 没有这个字段,只能规定模型按剧本说话:
Thought: 我需要看目录
Action: bash
Action Input: ls -la
那才是你在规定「怎么思考、下一步怎么输出」。解析错一行循环就断。OpenAI / DeepSeek / Anthropic 把这份剧本内置进 API 之后,不必再发明思考格式。System prompt 里那两句不是思维框架,是停机条件:没干完就继续吐 tool call,干完了就只吐字,好让 while 退出。
模型怎么「告诉你」要调工具
不是用中文对用户说「请调用 bash」。是 API 里另一条通道。
请求里只传菜单,不传执行器:
{
"model": "deepseek-chat",
"messages": [{"role": "user", "content": "列出当前目录"}],
"tools": [{
"type": "function",
"function": {
"name": "bash",
"description": "在工作区执行一条 shell 命令",
"parameters": {
"type": "object",
"properties": {"command": {"type": "string"}},
"required": ["command"]
}
}
}],
"stream": true
}
流式回复里两路并行:
| 通道 | 字段(OpenAI Chat Completions) | 对象 |
|---|---|---|
| 说话 | delta.content → message.content | 用户 |
| 点名工具 | delta.tool_calls → message.tool_calls | 你的代码 |
tool_calls 常常拆成好几包到达:先来 id 和 name,arguments 再像打字一样拼出来。最后一包的 finish_reason 经常是 tool_calls,意思是:这次不是最终答用户,停下来等你执行。
Loop 不要解析句子。看有没有结构化字段:有就本地跑,把 role: tool 按 tool_call_id 对回去;没有就把 content 当最终回答。用户若看到「我先列一下目录」,那只是可选的 content 旁白,没有旁边那份 JSON,本地一步都不会跑。
字段名并不统一,语义统一:
- OpenAI Chat Completions:
tool_calls - OpenAI Responses:
function_callitem - Anthropic:
tool_use/tool_result - dsh / 本仓库对照实现:content 里的
type: "tool-call" - pi / OpenClaw:content 里的
type: "toolCall",stopReason === "toolUse"
框架都会先归一化,再进同一套 while。说「都靠 tool_calls」在 OpenAI 口径下对;换成别家就不叫这个名字。
五家对照:内核同一句话
资料来自各家公开源码和文档(截至 2026-09-03)。产品层差很远,主路径没有一家在解析「Action: bash」。
| 模型怎么点名 | Loop 认什么 | 外面多出来的 | |
|---|---|---|---|
| DeepSeek Harness | 适配器把厂商流收成 tool-call 块 | message.content.filter(b => b.type === 'tool-call'),没有就 completed | 并行池、排他屏障、瀑布中间件、abort 补合成结果以便 JSONL 重放 |
| pi | convertToLlm 出门前才译成各家 API | content.filter(c => c.type === "toolCall") | 双层 while:内层工具链,外层 follow-up / steer |
| openai-agents-python | tool_calls / function_call | Runner.run:final output 停,handoff 换人,否则执行工具再转 | guardrail、tool_use_behavior、并发上限 |
| AgentScope | 各家 tools API → 统一 ToolCallBlock | _reasoning 没有 tool block 就当最终回答,有就 _acting | 中间件、HITL、并行 asyncio.gather |
| OpenClaw | 与 pi 同构,现已收成 @openclaw/agent-core | 更严:必须 stopReason === "toolUse" 且 有 toolCall 才 dispatch(避免 length/stop 里的半截块) | 频道网关、钩子、工具死循环恢复、可选 Codex runtime |
画出来都是:
用户 → 问模型 → 有结构化 tool call?
否 → 把 content 给用户,停
是 → 本地 / 沙箱执行 → 结果写回历史 → 再问模型
框架变厚,不是换了思考术。并行、HITL、steer、handoff、压缩、MCP,都是这个 while 外面的产品层。老 LangChain ReAct、Aider 的 diff 块才是文本协议,和这五家不是一代东西。
手写时到底要写哪三块
最小实现可以塞进一个文件。真要能续跑、能测、能换模型,拆成三块更清楚——这也是 DeepSeek Harness 精简对照实现里的分法。
provider 一次 HTTP。只负责把流式碎片收成 text / tool-call / finish
session 只追加事件。发给模型的 messages 是现推的,不另存一份
loop while:有 tool call 就执行并继续,没有就停
provider 里那个 async for line in response 不是 Agent Loop,只是在拼 SSE。思考—工具—再思考发生在 loop:执行完返回「还没结束」,外层继续下一步,下一步从 session 现推历史(用户话 → 带 tool_calls 的 assistant → role: tool 结果),再打一次全新的 chat completions。工具目录每一步都要再传一遍,因为模型没有跨请求的执行器。
1. 一次请求的组装器
流式到来的 tool_calls 是碎片。先收成一条 assistant 消息:
class BlockAssembler:
def __init__(self) -> None:
self.text_parts: list[str] = []
self.tool_calls: list[dict] = []
self.finish = {"kind": "completed"}
def push(self, chunk: dict) -> None:
kind = chunk["type"]
if kind == "text":
self.text_parts.append(chunk.get("content") or "")
elif kind == "tool-call":
self.tool_calls.append({
"id": chunk.get("id") or "",
"type": "function",
"function": {
"name": chunk.get("name") or "",
"arguments": chunk.get("arguments") or "{}",
},
})
elif kind == "finish":
self.finish = {"kind": chunk.get("kind") or "completed"}
def assistant_message(self) -> dict:
text = "".join(self.text_parts)
message = {"role": "assistant", "content": text or None}
if self.tool_calls:
message["tool_calls"] = self.tool_calls
return message
2. 历史从日志现推
不要维护一份「真正的 messages」再同步一份日志。只追加事件,请求前现推。重放同一个 JSONL,历史一定一致:
def derive_messages(events: list) -> list[dict]:
messages = []
for event in events:
if event["type"] == "user/message":
messages.append({"role": "user", "content": event["text"]})
elif event["type"] == "assistant/message":
messages.append(dict(event["message"]))
elif event["type"] == "tool/result":
messages.append({
"role": "tool",
"tool_call_id": event["call_id"],
"content": event["text"],
})
return messages
tool_call_id 必须对上 assistant 那条 tool_calls[].id。少一条、错一条,下一轮请求很多厂商会直接 400。OpenClaw 为此在 abort 时给没跑到的 call 补合成 tool_result,dsh 也是同一纪律。
3. 那个 while
内核就是这个判断。有 tool call 返回 None 表示「本轮没结束」,外层继续;纯文本返回 completed 停机:
async def step_once(session, provider, tools) -> dict | None:
request = build_request(session, tools) # messages=derive_messages(), tools=schema
assembler = BlockAssembler()
async for chunk in provider.stream(request):
session.append("assistant/chunk", chunk)
assembler.push(chunk)
if assembler.finish["kind"] == "error":
return {"kind": "error"}
message = assembler.assistant_message()
session.append("assistant/message", message)
if assembler.finish["kind"] == "max-tokens":
return {"kind": "max-tokens"} # 参数可能被截断,不要执行
tool_calls = message.get("tool_calls") or []
if not tool_calls:
return {"kind": "completed"}
await run_tools(session, tools, tool_calls) # 本地 invoke,追加 tool/result
return None # 让外层再走一步
外层可以写成:
async def turn(session, provider, tools, user_text: str) -> None:
session.append("user/message", {"text": user_text})
for _ in range(MAX_STEPS):
ended = await step_once(session, provider, tools)
if ended is not None:
return
session.append("turn/end", {"kind": "max-steps"})
run_tools 里参数解析失败、工具不存在、执行抛错,都写成 Error: ... 字符串追加回去,不要把异常抛出循环。模型下一轮自己决定重试还是改口。这是五家的共同做法。
走一遍「列出当前目录」:
step 1 请求:[user] + tools 菜单
模型:tool-call bash {"command": "ls -la"} ← 只是声明
本地:真的执行 ls,写入 tool/result
step 2 请求:[user, assistant+tool_calls, tool 结果] + 同一份菜单
模型:根据 ls 输出写回答,或再点一次工具
第二次请求里模型看到的是自己刚才的点名,加上你刚跑出来的结果。它自然会接着想。你没有、也不该再设计一套 Thought/Action 文本。
手写时真正要拍板的六件事
内核写完之后,剩下的才是设计,而且每一项都有「先别做」的默认值。
1. 停机。
最少两道闸:没有 tool call、步数上限。另外建议单独处理 max-tokens:pi 会把截断的 tool call 整批标失败,OpenClaw 直接不 dispatch。半截 JSON 拿去 json.loads 再执行,是在赌。
2. 错误回填。
工具失败写成 tool 消息,不要拆掉 tool_call / tool_result 配对。循环可以死,历史不能缺腿。
3. 串行还是并行。
第一版串行就够。dsh 用排他屏障 + 有界滚动池,pi / OpenClaw 看工具是否标了 executionMode: "sequential"。文件系统工具并行会互相踩,bash 尤其危险。
4. 中途插入。
用户在工具跑到一半时改口,是产品能力,不是内核。dsh 用 next-turn / next-step 双队列,pi / OpenClaw 用 steer / follow-up。第一版可以不做;要做,记住:插入的是下一条 user 消息,不是改模型已经吐出的 tool call。
5. 流式给谁看。
provider 已经在吐 text chunk。UI 订这些 chunk 即可。不要为了「好看」再让模型用自然语言复述一遍工具参数——那是给用户的旁白,不是协议。
6. 不要发明的东西。
不要自己规定 Thought/Action/Observation 文本;不要在本地用正则从 content 里抠「调用 bash」;不要假设模型会跨请求记住工具结果;不要把工具的 Python 对象传给模型,只传 schema。
什么时候不该手写
为了搞懂协议,手写一次值得,一百行内核就够。为了做一个能接 WhatsApp、能并行、能压缩上下文、能审批危险工具的产品,直接站在现成 runtime 上:
- 要嵌入自己的 Python 应用:openai-agents 或 AgentScope
- 要 coding harness、JSONL 重放、插件:DeepSeek Harness
- 要最小 TypeScript 内核:pi,OpenClaw 是它套上频道之后的样子
你要补的永远是那一层运行时,不是另一套认知架构。模型负责点名,Loop 负责世界。
本文只回答一件事:Loop 本身有多薄,以及手写时认哪一个字段。 认准这个边界,Agent Loop 就可以还原成一个清楚的 while:模型负责点名,循环负责执行、回填和决定何时结束。
