Back to Journal
02 / Entry· 5 min read

Agent Loop 其实不复杂:模型只负责点名,循环负责手和脚

对照 dsh、pi、openai-agents、AgentScope、OpenClaw 之后的结论:现代 Agent Loop 不是在规划模型怎么思考。模型用结构化字段点名工具,你的 while 负责执行、回填、再问、停机。附一份能直接抄的手写骨架。

🔊 系统朗读

对照过 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 里另一条通道。

请求里只传菜单,不传执行器:

json
{
  "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.contentmessage.content用户
点名工具delta.tool_callsmessage.tool_calls你的代码

tool_calls 常常拆成好几包到达:先来 idnamearguments 再像打字一样拼出来。最后一包的 finish_reason 经常是 tool_calls,意思是:这次不是最终答用户,停下来等你执行。

Loop 不要解析句子。看有没有结构化字段:有就本地跑,把 role: tooltool_call_id 对回去;没有就把 content 当最终回答。用户若看到「我先列一下目录」,那只是可选的 content 旁白,没有旁边那份 JSON,本地一步都不会跑。

字段名并不统一,语义统一:

  • OpenAI Chat Completions:tool_calls
  • OpenAI Responses:function_call item
  • 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-callmessage.content.filter(b => b.type === 'tool-call'),没有就 completed并行池、排他屏障、瀑布中间件、abort 补合成结果以便 JSONL 重放
piconvertToLlm 出门前才译成各家 APIcontent.filter(c => c.type === "toolCall")双层 while:内层工具链,外层 follow-up / steer
openai-agents-pythontool_calls / function_callRunner.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 消息:

python
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,历史一定一致:

python
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 停机:

python
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                                       # 让外层再走一步

外层可以写成:

python
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:模型负责点名,循环负责执行、回填和决定何时结束。

分享
← 返回博客列表
🎁 有邀请福利哦,点击查看
🎁