动手写一个 Agent
用 Pi 作为模板, 拆解, 消化, 然后随心所欲做一个 Agent 出来
怎么入门 LLM Agent ?
自上而下的办法是给各种系统对接 LLM. 自下而上的办法是写一个 LLM Agent.
本文讲自下而上这个笨办法, 参考 Pi 源码 写个 Coding Agent, 然后跑 Benchmark 验证质量.
Agent 的核心是 agent loop, 我们从核心开始.
Agent Loop
先把 AgentLoop 当黑盒从接口开始理解. AgentLoop 是一个过程(process), 过程接口由输入、输出、副作用来定义.
对比 chatbot, 输入多了 tool definitions 和 executor, 输出多了 tool call 和 tool result messages, 还有副作用.
这些接口信息有点浅, 我们要深入 loop 内部运作.
Agent Events
可以把过程划分阶段, 来固定内部的实现.也能让外部”扩展”这个过程, 就是在阶段之间插入 hook, Pi 里叫 emit event.
看看 Pi 的阶段事件设计:
prompt("Read config.json")
├─ agent_start
├─ turn_start
├─ message_start/end { userMessage }
├─ message_start { assistantMessage with toolCall }
├─ message_update...
├─ message_end { assistantMessage }
├─ tool_execution_start { toolCallId, toolName, args }
├─ tool_execution_update { partialResult } // If tool streams
├─ tool_execution_end { toolCallId, result }
├─ message_start/end { toolResultMessage }
├─ turn_end { message, toolResults: [toolResult] }
│
├─ turn_start // Next turn
├─ message_start { assistantMessage } // LLM responds to tool result
├─ message_update...
├─ message_end
├─ turn_end
└─ agent_end
最外层叫 agent 阶段, 第二层是 turn 阶段, 开始有 loop 性质. turn 里面是”叶子”, message 阶段和 tool_execution 阶段. 注意它们有 update 事件, 说明 Pi 原生支持流式输出.
这里 message 包含了 assistant 的 thinking 和 tool-call messages, 它们就是 ReAct 里的 Reasoning 和 Action.

图片中 LLM 这个大脑, 参与了 Pi 的哪些阶段呢?
连接 LLM
什么时候发请求给 LLM ?
显然, 发生 message_end{ userMessage } 时会一次请求给 LLM, 但 userMessage 只有在 loop 第一轮才作为新消息加入, 后面几轮呢?
如果在新消息加入后就发请求, 那应该在 message_end{ toolResultMessage } 时发.
但 Pi 把 message_end{ toolResultMessage } 放在 turn_end 前, 也就是说这样一轮会发两次请求, 连接还跨 turn 了, 显然不好.
所以 Pi 在 message_end 加入新消息时, 不一定会发请求.
ToolResult 触发的请求是在 turn_start 后发出的.
LLM 什么时候结束响应这次请求?
按 message_end{ assistantMessage } 定义, LLM 在它之前结束响应.
prompt("Read config.json")
├─ agent_start
├─ turn_start
├─ message_start/end { userMessage } // Start LLM request
├─ message_start { assistantMessage with toolCall }
├─ message_update...
├─ message_end { assistantMessage } // LLM response stop here
├─ tool_execution_start { toolCallId, toolName, args }
├─ tool_execution_update { partialResult }
├─ tool_execution_end { toolCallId, result }
├─ message_start/end { toolResultMessage } // Add messages without request LLM
├─ turn_end { message, toolResults: [toolResult] }
│
├─ turn_start // Start another request
├─ message_start { assistantMessage }
├─ message_update...
├─ message_end // End response
├─ turn_end // Last turn end
└─ agent_end
所以 turn 里有不少步骤是不一定发生的, 例如 message_start/end{ userMessage }, tool_execution_start/update/end 和 message_start/end{ toolResultMessage }.
后者刚好也是判断 loop 要不要停下来时机: 最后一个 turn 没有 tool result observation 要给 LLM.能不能简单说”执行了 Tool 就要继续请求 LLM” 呢? 不行, 有些 tool 需要退出 loop 把控制权交出去, 例如 ask_user_questions UI tool 需要用户选答案, 作为新的 input 开启下个 agent loop.
LLM
信息在 Agent 和 LLM 这两个层面来回传递, 非常适合分层.
LLM 要支持流式输出, 所以也有阶段事件:
AssistantMessageEvent
├─ start
├─ text_start
├─ text_delta
├─ text_end
├─ thinking_start
├─ thinking_delta
├─ thinking_end
├─ toolcall_start
├─ toolcall_delta
├─ toolcall_end
├─ done
└─ error
仔细看看交互.
流程清楚了, 那我们给 LLM 的内容是什么?
Prompt
拿 OpenAI 经典解释 Codex 文章里的图来看:

- System Prompt.
- Tool Definitions.
- Messages.
这些作为输入传给 LLM API.
LLM API Provider
LLM generate 实现要解决的问题, 高层次地讲, 是如何把上层的 Agent Message “lowering” 到底层的 LLM dialect Message, 以及反过来如何把 LLM dialect Message “lift” 到 Agent Message. 低层次地讲, 就是对接 API 供应商.
我们对接新一点的 OpenAI Responses API 协议, 学习一下 OpenAI 对 Agent 的理解.刚好便宜模型 deepseek-v4-flash 官方服务最近支持了 Responses API.
Responses API
Responses API 比 Chat Completions API 多了点东西:
-
支持服务端存状态(
previous_response_id).看着很先进, 但它会过期!(30 天 TTL) 这就非常鸡肋了. 一旦用户想继续一个月前的对话, 你就要处理
previous_response_not_found这多出来的状态分支, 还是要做本地 history messages 重建.Codex 自己都不用previous_response_id, 刚好 Deepseek 实现 Responses API 去接 Codex 也不用费力去支持这个服务端状态.
-
message 除了 content 主内容, 还加了不少 metadata.
例如所有 message 都加了 id (tool call 有
call_id和id). text message 有phase("commentary"|"final_answer"|null), 文档还强调:… dropping it can degrade performance.
Pi 的处理方法是
- 拼字段.
{call_id}|{id}拼成一个ToolCall.id - 加一个
signaturejson 字段, 给 provider 自己看着用.
- 拼字段.
细节太多, 搞得跟工作里接供应商一样, 召唤 GPT 全写了.后果是这段 VibeCoding 导致写文章时对生成AssistantMessage的时序理解出错了, 差点写错.
看 AgentLoop 的另一块, tool execution.
Tools
Tools 是 Agent 工程里很脏的部分, 同时也是玩具和有生产价值的 Agent 的区分点. 优化它就是在 harness LLM.
Pi 以极简主义出名, 默认只启用 4 个 tool: read,write,edit,bash. 即便这样, 也要优化工具, 在各种情况下都给出长度合理, 有信息量的结果.
Context Engineering
Pi 在管理 Tool Result Context 的主要措施有:
- 结果包含提示.
- 结果限制上限.
- 容错, 自动转换.
举个例子, read tool 接受 path 文件路径和 offset, limit 按行范围读内容, 简洁方案. 但它里面处理了:
- 行数/字节硬上限, 防止污染上下文.
- 达到上限截断, 给 LLM 截断后内容和提示.
offset,limit参数范围容错.- 识别图片和格式.
- 图片超大图自动缩放, 给 LLM 缩放图和提示.
- LLM 不支持视觉时提示.
Pi 里 read 截断后给模型的提示就细分三种:
- 行数截断:
[Showing lines X-Y of Z. Use offset=N to continue.] - 字节截断:
[Showing lines X-Y of Z (50.0KB limit). Use offset=N to continue.] - 首行单独超过 50KB:
[Line N is XKB, exceeds 50.0KB limit. Use bash: sed -n 'Np' path \| head -c N]
我们没精力维护庞大的 edge-cases , 又想做得不那么像玩具, 可以用一个取巧的办法, 就是把 Code Interpreter 给 LLM , 让它用上现有工具生态和灵活组装工具.
我们给它经典的 Bash.
Bash Tool
bash tool 没有比 read tool 复杂太多. 只需要 command 和 timeout 参数.
Pi 处理了:
- stdout/stderr 和进程退出码.
bash 也要管理输出长度, 不过它截断保留的是末尾部分, 同时把完整输出写到临时文件, 提示 LLM 可以”续读”.bash 通常有副作用, 命令结果很重要. Pi 用个
OutputAccumulator流式存储结果. - 进程树
- 环境变量. 注入一些
PI_*session 环境变量让 LLM 感知 Pi 环境.
支持了 tool execution, 再给 LLM 提供 tool 信息就能用了.
Tool definitions
提供给 LLM 的 tool definitions, 常见的是 Function Calling 形式, 也就是 name, description, parameters (JSON Schema).OpenAI 还支持 Hosted Tool, 也就是 tool 不在 agent 端执行, 而是在 LLM Provider 执行, 例如 web_search. 还有 Custom Tool, LLM 写普通 text 而不是 json, 声明 regex/lark grammar 来限制 LLM 输出.
{
"name": "bash",
"description": "Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last 2000 lines or 50KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"description": "The bash command to execute"
},
"timeout": {
"type": "integer",
"description": "Optional timeout in seconds",
"minimum": 1
}
},
"required": ["command"],
"additionalProperties": false
}
}
在请求 LLM provider 时把 tool definitions 传入, 在收到 tool call message 根据 name 找到对应的 tool, 再解析 json 取到参数就可以执行了.
System Prompt
一个正常的 Agent 环境, 想影响和预估 LLM 的行为, 除了 Tools , 就基本靠 System prompt. 看一下 Pi 的 System prompt:
You are an expert coding assistant...
Guidelines:
${guidelines}
Pi documentation...
<project_context>
Project-specific instructions and guidelines:
<project_instructions path="${filePath}">
${content}
</project_instructions>
</project_context>
The following skills provide specialized instructions...
<available_skills>
<skill>
<name>${escapeXml(skill.name)}</name>
<description>${escapeXml(skill.description)}</description>
<location>${escapeXml(skill.filePath)}</location>
</skill>
</available_skills>
Current working directory: ${promptCwd}
大概分为:
- Agent 定位.
- 工具指南(Guidelines).
Pi 要提供优秀的扩展性, 所以 tool 提供方可以在
ToolDefinition的promptGuidelines字段写一些指导和最佳实践给 LLM. - (Pi) 环境信息.
- 用户的指令 (AGENTS.md).
- Skills. 格式参考 agentskills.io.
- (项目) 环境信息. 通常与任务更相关.
从 Loop 到应用
距离一个 real world 应用还差什么?
做最简单的 CLI 命令行工具, 那至少要有输入、输出吧.
CLI -p
先写个 CLI. 参考 Pi/Claude Code -p 参数, 让用户可以执行非交互性任务. 例如:
$ mypi -p "What's the capital of France? Respond with only the city name."
Paris
把最后的 message content 打印出来就行.
多轮对话
Agent 应用通常支持多轮对话(称为 session 或者 thread), 下面是 OpenAI 解释 Codex 的另一张图:注意跟 Pi AgentLoop 里更小的 turn 区分开来. OpenAI 说的 turn 是完整的一个 AgentLoop.
内层回环是 AgentLoop, 由 Tool Result 驱动. 外层回环是 SessionLoop, 由新的 User Input 驱动.
我们用一个 AgentSession 来管理, 在多轮 AgentLoop 间复用同一个 message buffer 就能支持多轮对话.虽然 benchmark 通常不需要多轮对话, 但最好有重试功能, 而在 AgentLoop 上游(Session)和下游(LLMProvider) 实现重试都更简洁干净.
Session Journal
Agent 应用通常还支持重启/继续某次对话, 甚至分析 Agent 轨迹, 所以要持久化 session.
session 常见持久化方案是 jsonl 文件, 类似 Event Sourcing. 这里监听 AgentLoop 发的阶段事件(Agent Events), 过滤 user/assistant/tool-result message , 包一层追加写入 jsonl 文件.
1{"kind":"header","version":4,"id":"13980947","createdAt":1787111079566,"cwd":"/Users/xieziheng/projects/pi.mbt"}
{
"kind": "header",
"version": 4,
"id": "13980947",
"createdAt": 1787111079566,
"cwd": "/Users/xieziheng/projects/pi.mbt"
}2{"kind":"entry","lane":"main","id":"18352111","seq":1,"parentId":null,"timestamp":1787111079568,"type":"message","message":{"role":"user","content":[{"type":"text","text":"What's the capital of France? Respond with only the city name."}]}}
{
"kind": "entry",
"lane": "main",
"id": "18352111",
"seq": 1,
"parentId": null,
"timestamp": 1787111079568,
"type": "message",
"message": {
"role": "user",
"content": [
{
"type": "text",
"text": "What's the capital of France? Respond with only the city name."
}
]
}
}3{"kind":"entry","lane":"main","id":"10156916","seq":2,"parentId":"18352111","timestamp":1787111081854,"type":"message","message":{"role":"assistant","content":[{"type":"thinking","thinking":"The user asks for the capital of France and wants only the city name. This is a simple question.","thinkingSignature":"{\"type\":\"reasoning\",\"id\":\"2703a60b-8628-4d3b-a744-0f3c5d01b034\",\"status\":\"completed\",\"content\":[{\"type\":\"reasoning_text\",\"text\":\"The user asks for the capital of France and wants only the city name. This is a simple question.\"}],\"summary\":[],\"encrypted_content\":\"acb023de-1ceb-4623-a832-5f84dbce09df-0\"}"},{"type":"text","text":"Paris","textSignature":"{\"type\":\"message\",\"id\":\"2ce8b38f-f7af-40d0-9ba8-3f8252281ae0\",\"phase\":\"final_answer\"}"}],"api":"openai-responses","provider":"deepseek","model":"deepseek-v4-flash","stopReason":"stop","errorMessage":null,"usage":{"input":49,"output":23,"cacheRead":1664,"cacheWrite":0,"cacheWrite1h":null,"reasoning":21,"totalTokens":1736,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"timestamp":1787111081321}}
{
"kind": "entry",
"lane": "main",
"id": "10156916",
"seq": 2,
"parentId": "18352111",
"timestamp": 1787111081854,
"type": "message",
"message": {
"role": "assistant",
"content": [
{
"type": "thinking",
"thinking": "The user asks for the capital of France and wants only the city name. This is a simple question.",
"thinkingSignature": "{\"type\":\"reasoning\",\"id\":\"2703a60b-8628-4d3b-a744-0f3c5d01b034\",\"status\":\"completed\",\"content\":[{\"type\":\"reasoning_text\",\"text\":\"The user asks for the capital of France and wants only the city name. This is a simple question.\"}],\"summary\":[],\"encrypted_content\":\"acb023de-1ceb-4623-a832-5f84dbce09df-0\"}"
},
{
"type": "text",
"text": "Paris",
"textSignature": "{\"type\":\"message\",\"id\":\"2ce8b38f-f7af-40d0-9ba8-3f8252281ae0\",\"phase\":\"final_answer\"}"
}
],
"api": "openai-responses",
"provider": "deepseek",
"model": "deepseek-v4-flash",
"stopReason": "stop",
"errorMessage": null,
"usage": {
"input": 49,
"output": 23,
"cacheRead": 1664,
"cacheWrite": 0,
"cacheWrite1h": null,
"reasoning": 21,
"totalTokens": 1736,
"cost": {
"input": 0,
"output": 0,
"cacheRead": 0,
"cacheWrite": 0,
"total": 0
}
},
"timestamp": 1787111081321
}
}Terminal Bench
终于到了跑分时刻.就是为了这点醋, 我才包的这顿饺子.
我们用terminal-bench@2.1 验证, 它的任务基本是给一个终端命令行, 让 Agent 执行, 然后验证结果是否正确. Deepseek 官方说 DeepSeek-V4-Flash 的 Terminal Bench 2.1 分数是 82.7.
terminal-bench 有官方运行器 harbor. 原理是按任务定义构建 docker 镜像, 用容器跑每个任务, 记录日志和结果, 用 test.sh 验证结果.
我们可以在本地跑 harbor, 也可以在云端跑 harbor, 例如(免费的) GitHub Actions.
最后结果: 64/89 = 71.9% vs DeepSeek Harness 82.7%, -10.8% 的差距.
我(让 K3)分析了下 journal, 发现不少任务超时, 可能跟 GitHub Action 环境有关. 还有个很曲折的 Agent 轨迹, 是跑 build-pov-ray.
这个任务要编译 1996 年的 POV-Ray 2.2, 渲染样例场景 illum1.pov.
Agent 发现 povray.org 被 Cloudflare 拦了, 自己去 Wayback Machine 翻出当年的源码包, 处理 CRLF 行尾和 ZIP 大写文件名, 手写适配现代 gcc 的 makefile, 编译通过, 渲染结果达标.
然后因为没保留 ZIP 里一个 file_id.diz 元数据小文件, 被判 FAIL. 如果都是这种失败, 我觉得这成绩也还行.
我们的 Agent 还不错.Deepseek 涨价了, 一次 benchmark 41 块钱, 不想跑第二次.
回顾
“人无法用 AI 做出自己不了解的东西”, 在 2026 年 LLM AI 时代, 多数时候都不成立了.
我在做这 Agent 时, 为了省时间, 让 coding agent 帮我对接 Responses API 和对齐 pi session jsonl, 最终成品能正常跑过 benchmark.
只是在写文章时, 才发现自己理解有漏洞, 个人的知识边界变得模糊. 回过头去看代码, 与 AI 讨论, 反而能找出几个 AI 写的 bug.
可能在做深、做大产品上, 个人的理解还是有加速 AI 产出和扩展边界的(商业)价值.
对个人来说, 满足了我”知识分子”的虚荣心.
不过, 如果理解计算机图形学, 会更能欣赏下面的画?我为了拿渲染图让 ds flash 重跑一遍 build-pov-ray, 它发现这份 1996 年的代码自带一个随机 bug (void main 导致退出码未定义), 渲染成功也有 ~25% 概率返回错误退出码 53. 它顺手修掉后通过了 benchmark verifier.
