大模型对话协议讲义:手写 Agent 的第一课
循环好写,协议难——手写 Agent 必须精确知道消息长什么样、tool_calls 怎么接、流式怎么拼。本讲义以 Chat Completions 为主线,对照 Responses 与 Messages,最后落到"手写 Agent 协议 Checklist"。
概览:这份讲义的核心
序章 · 什么是大模型的对话协议
对话协议 = 你与模型服务之间的"消息结构契约"
在比较"三种协议哪家强"之前,先回答更根本的问题:大模型(LLM,Large Language Model)的对话协议(Chat Protocol)到底是什么?一句话——它是客户端与 LLM 推理服务(通过 API——Application Programming Interface,应用程序接口——调用)之间预先约定的一套消息结构:你按这个结构组装 JSON(JavaScript Object Notation,一种文本数据格式)发过去,服务端才能读懂你;服务端按约定的结构返回,你的程序才能读懂模型。
- 一句话定义:对话协议是一层 JSON 应用结构约定,约定四件事——消息怎么写、结果怎么收、工具怎么调、流式怎么传
- 它不在传输层发明新东西:底层就是普通 HTTP POST + JSON 请求/响应体;"协议"指的是这层结构契约,而不是 TCP/HTTP 那种网络协议
- 契约的双方:你的程序(客户端:组装请求、维护历史、执行工具)与推理服务(服务端:理解输入、生成输出、报告结束原因)
- 为什么它排第一课:手写 Agent 的主循环每一步都在执行协议语义——分支看 finish_reason、记忆靠 messages 回放、动手靠 tool_calls、打字机效果靠 delta
- 边界预告:与它并列的还有 MCP(连工具生态)、A2A(Agent 互联)——那是另外两层的"协议",口语里常被混为一谈,第一篇卡片 2 会展开
最小的一问一答:5 个字段看懂协议骨架
概念说完,把协议拿在手里看一次。以 Chat Completions 为例,一次最小对话只需要认识 5 个字段——后面所有章节(工具调用、流式、协议差异)都是在这副骨架上做扩展。
- 请求:POST /v1/chat/completions,body 里 model 指定用哪个模型,messages 携带对话历史
- 一条消息 = 角色 + 内容:{"role": "user", "content": "你好"}——system / user / assistant / tool 四种角色撑起整个对话结构
- 响应:生成的文本在 choices[0].message.content,结束原因在 finish_reason("stop" 表示自然说完)
- 多轮没有魔法:把 assistant 的回答 append 进 messages 再整体重发一次——对话的"记忆"就是客户端里的这个数组(第二篇展开)
- 骨架之上的扩展:tool_calls(第三篇)让模型能"动手",stream + delta(第四篇)让输出变成流式
第一篇 · 全景:2026 年的三种协议
事实标准 / Agent 原语 / Claude 生态
2026 年的三种主流协议各司其职:新项目对接 OpenAI 新模型用 Responses;跨厂商兼容仍走 Chat Completions;用 Claude 用 Messages。
- OpenAI Chat Completions:POST /v1/chat/completions——"大家都模仿的普通话",全生态兼容
- OpenAI Responses API:POST /v1/responses——OpenAI 官方新一代(2025.03 起),GPT-5 起新模型只在它上提供,Chat 进入维护模式
- Anthropic Messages API:POST /v1/messages——Claude 生态标准,Agent-oriented 但保持无状态
- 关键时间线:2023.06 Chat Completions 诞生;2025.03 Responses 发布;2026 年 GPT-5 之后只通过 Responses 提供;Assistants API 已于 2026-08-26 正式下线
LLM API / MCP / A2A:别把三层协议混为一谈
口语里说"Agent 协议"的人,有的指 MCP、有的指 LLM API——先确认对方说的是哪一层。三者是互补关系。
- LLM API:解决"怎么把 prompt 发给模型、怎么拿回生成结果和 tool_calls"——本讲义主题
- MCP(Anthropic 提出):解决"Agent 怎么以标准化方式连接成百上千个外部工具/数据源"——Anthropic 主推
- A2A(Google 提出):解决 Agent 之间互相协作——还在早期
- 互补关系:连模型用 LLM API,接工具生态可上 MCP,跨 Agent 协作才需要 A2A
职责:把 prompt 发给模型、拿回生成结果与 tool_calls
Chat Completions · Responses · Messages —— 本讲义主题"] L2["中层 · MCP(Agent ↔ 工具/资源)
职责:Agent 以标准化方式连接成百上千个外部工具与数据源
Anthropic 提出并主推"] L3["底层 · A2A(Agent ↔ Agent)
职责:Agent 之间互相协作
Google 提出 · 仍在早期"] L1 --> L2 L2 --> L3 L1 -.互补关系:三层各管一段,不互相替代.-> L3
第二篇 · Chat Completions 核心机制
无状态 + 全量回放:服务端不记忆任何东西
每轮请求都要把完整对话历史重新发一遍。可以类比:每次进餐厅都要把全部点餐历史从头报一遍,服务员听完做好这一道菜,然后立刻把你忘干净。
- 维护 messages 数组:"记忆"就是你客户端里的这个 list,服务端完全不存
- token 计数与预算:服务端只报数(usage),不替你管超限
- 上下文压缩/截断:超窗必须自己截——压缩永远在 harness 侧强制执行(AgentDiet 原则)
- 持久化/恢复:断点续跑 = 把数组存盘再读回
- 本仓 shell_runner.py 的 BudgetGuard(OLLAMA_SHELL_CTX_BUDGET)就是"超预算保留 system + 最近 K 轮"的产品化实现
finish_reason 四态机:循环分支唯一依据
手写 Agent 的主循环只认 finish_reason 做分支,四种取值必须全覆盖——任何一态漏处理都会让循环陷死或丢工具结果。
- stop:自然结束 → 收尾、提交答案、退出循环
- tool_calls:模型要求调工具 → 执行 → role:"tool" 回填 → 再次请求(核心循环)
- length:撞 max_tokens 被截断 → 扩预算或续写,不要盲目重试
- content_filter:触发安全策略 → 降级 / 改写 / 上报
- 用 if response.choices[0].message.content: 判循环结束?反模式:模型完全可能返回空 content + 有效 tool_calls,会把工具循环掐死
第三篇 · Tool Calling:Agent 的心脏
Tool Loop:arguments 是 JSON 字符串、并行要全回填、id 严格配对
40 行核心循环就能跑通手写 Agent,但 90% 的事故都来自三个边界:tool_call_id 配错、arguments 没 try/except、并行调用少回填一个。
- tool_call_id 必须严格配对:tool 消息的 tool_call_id 与 assistant tool_calls[].id 一一对应,错一个服务端直接 400
- arguments 是 JSON 字符串不是对象:"arguments": "{\"suite\": \"all\"}"——收到后要 json.loads,且必须 try/except:弱模型输出非法 JSON 很常见
- 并行调用要全部执行完再回填:一条响应可能带多个 tool_calls,逐个执行、逐个 append,全部完成后再发起下一轮请求
- 解析失败的正确处置:把错误信息作为 tool 结果回填(ERROR: invalid JSON in arguments),让模型下一轮自我修正,而非崩溃
- 用 experiment_modules/solving/adapters/ollama/_client.py(30 行裸协议客户端)+ 上面 40 行循环,组合成 70 行最小可跑 Agent 骨架
第四篇 · 流式协议(SSE)
delta 聚合四条规则 + [DONE] 哨兵 + finish_reason 在末尾
"stream": true 时服务端返回 SSE 流。增量在 delta 而非 message,流式 / 非流式协议语义等价(拼完 delta 等于非流式 message),但超时/心跳处理需要流式特有机制。
- 增量在 delta 而非 message:把每个 chunk 的 delta.content 顺序拼接才是完整文本
- tool_calls 的 arguments 同样分片到达 → 按 delta.tool_calls[].index 聚合,先拼 name 再拼 arguments
- arguments 拼接完成后再一次性 json.loads,中途解析必然失败
- [DONE] 是终止哨兵,收到即关闭连接;finish_reason 出现在最后一个 chunk的 delta 旁
arguments 拼完再一次性 json.loads L-->>C: 最后一个 chunk · delta 旁带 finish_reason L-->>C: data: [DONE] 终止哨兵 → 关闭连接
- Web 前端消费 SSE 用浏览器原生 EventSource;FastAPI 侧用 sse-starlette。本仓 swebench-exp-web 的 /api/jobs/{id}/events + Last-Event-ID 回放就是同一套 W3C SSE 语义的工程化范例
第五篇 · 协议差异与兼容端点陷阱
system 位置 / 工具回填 / max_tokens 必填 / 缓存
Messages 与 Chat 三个结构性差异一起决定了协议严格度上限。Responses 进一步把状态管理从客户端挪到服务端。
- system 位置:Chat 用 messages[0].role="system";Anthropic 用顶层 system 参数(专门缓存与优先级语义)
- 工具调用响应:Chat 用 message.tool_calls[];Messages 用 content[] 中的 tool_use block
- 工具结果回填:Chat 用独立 role:"tool";Messages 用 role:"user" + 内嵌 tool_result block
- max_tokens:OpenAI 可选 / Anthropic 必填——做兼容层时给 Anthropic 侧补默认上限
- 上下文缓存:OpenAI 自动 / Anthropic cache_control 显式断点
OpenAI 兼容是方言级的,不是语言级的
提供 /v1/chat/completions 兼容端点的服务有 Ollama、vLLM、DeepSeek、阿里 DashScope、Together、Groq 等。但每个 provider 都在标准字段之外私货扩展参数,且默认值各异——静默失效比报错更危险。
- Ollama:options.num_ctx 默认 4096,长 prompt 被静默截断
- OpenAI:reasoning_effort,推理模型专属
- Anthropic:thinking / cache_control,扩展思考与显式缓存
- DashScope:enable_thinking,思考开关
- 本仓 one_shot_runner.py 的 D4 决策:Ollama 默认 4096,SWE-bench(SWE = Software Engineering,软件工程;bench = benchmark,基准测试)长 prompt 会被静默截断 → 透传 options.num_ctx=32768。旧版本 Ollama 对未知字段静默忽略不报错——这就是"方言"的典型代价:兼容端点不等于行为一致
第六篇 · 设计律与手写 Checklist
API 协议 ≠ scaffold 协议:可靠性梯度决定设计
手写 Agent 必须分清两层协议:API 协议是服务端保证(JSON 结构可靠);scaffold 协议取决于模型能力。弱模型走纯文本协议 + 正则解析比强行 JSON tool_call 更可靠。
- 结构可靠性梯度:unified diff(需算 @@ 行号)最差 / JSON tool_call(需嵌套转义)差 / 纯文本 bash 块(抄写+填空)最好
- 设计律:给模型的输出格式按"抄写+填空"设计,绝不按"计算+对齐"设计
- 本仓实测:模型生成内容结构可靠性梯度——纯文本 bash 块实测 100% 可靠(弱模型 60 次调用),JSON tool_call 是失效点
- 弱模型走纯文本协议 + harness 侧正则解析(空壳模式),比强行走 JSON tool_call 更可靠。这是 scaffold 协议层的自由度——API 协议层没有这个自由
- 如果让你设计一个能跨强弱模型跑 SWE-bench 的混合方案,你会在 scaffold 层加哪些护栏?
手写 Agent 协议 10 条 Checklist
按此清单逐项核对,可覆盖 90% 的协议层事故。
- 状态管理:明确自己维护 messages 数组(Chat/Messages)还是用 previous_response_id(Responses)
- 循环状态机:以 finish_reason / stop_reason 为唯一分支依据,4 种取值全覆盖
- tool_calls 解析防御:arguments 是 JSON 字符串 → try/except → 失败时把错误信息回填让模型重试
- 并行调用:一次响应多个 tool_calls,全部执行、逐个带 id 回填,缺一不可
- 回放完整性:assistant 轮的 tool_calls 字段原样保留进历史;tool_call_id 严格配对
- 上下文预算:每轮读 usage 记账,超限走截断/压缩策略(压缩永远在 harness 侧做,AgentDiet 原则)
- 流式拼接:delta 按 index 聚合,arguments 拼完再 json.loads;[DONE] 哨兵收尾
- 重试纪律:429/5xx 指数退避;length 截断不要盲目重试(先扩预算)
- 方言参数:num_ctx(Ollama) / reasoning_effort(OpenAI) / thinking(Anthropic)逐 provider 验证,勿信默认值
- scaffold 按模型强弱选型:强模型可上 JSON tool_call;弱模型优先纯文本协议 + 正则解析
第七篇 · FAQ 与实战阅读路线
常见问题六答:为什么裸协议、Chat 是否白学、多模态怎么传
读者实操时的高频问题。Q1 问取舍,Q2 问投入回报,Q3-6 问工程落地细节。
- 为什么不直接用 SDK?可以且推荐生产使用。手写裸协议的价值是教学与调试:出问题时你能分清是 SDK 的锅、协议理解的锅还是模型的锅。本仓 _client.py 坚持纯标准库,另一动机是零依赖可移植
- Chat Completions 会不会被 Responses 淘汰?新功能确实只迭代在 Responses,但生态兼容层短期仍以 Chat 为通用语言,学它是一次投入、处处能用
- system prompt 放 messages[0] 和放顶层有区别吗?行为上各 provider 有细微权重差异;工程上更大区别是缓存命中与计费——大 system 每轮重放很贵,Anthropic 的 cache_control 可显著降低成本
- tool calling 和 function calling 是一个东西吗?是。早期 OpenAI 叫 function calling(2023),后来统一为更通用的 tool calling(tools 数组)。老文档/老代码里两个名字混用
- 流式和非流式对 Agent 逻辑有影响吗?协议语义等价(拼完 delta 等于非流式 message),但有超时差异:流式下"模型在生成"和"连接死了"需要心跳/超时区分,本仓 shell_runner 的三级超时(CMD_TIMEOUT ⊂ MAX_STEPS ⊂ TIMEOUT)就是为此设计
- 多模态(图片)怎么传?content 从字符串升级为 blocks 数组:[{"type":"text","text":"..."}, {"type":"image_url","image_url":{"url":"data:image/png;base64,..."}}]。Anthropic 用 {"type":"image","source":{...}}。结构差异同 §6 思路
- 实战阅读路线 1→7:_client.py → one_shot_runner.py → shell_runner.py → shell_prompt.py → shell_guards.py → swebench-exp-web SSE → SPEC-ollama-shell-runner 决策记录
附录 · 术语速查
| 术语 | 含义 |
|---|---|
| Chat Completions | OpenAI 事实标准对话 API(/v1/chat/completions) |
| Messages API | Anthropic 原生协议(/v1/messages) |
| Responses API | OpenAI 新一代 Agent 原语 API(/v1/responses) |
| tool calling / function calling | 模型请求调用你定义的工具的机制 |
| tool_call_id / tool_use_id | 工具调用的配对标识(OpenAI / Anthropic 命名) |
| finish_reason / stop_reason | 响应结束原因(OpenAI / Anthropic 命名) |
| SSE | Server-Sent Events,流式返回的传输层 |
| delta | 流式响应中的增量片段 |
| OpenAI-compatible | 第三方服务实现 OpenAI 协议格式的兼容端点 |
| MCP | Model Context Protocol,Agent 连接工具生态的标准 |
| A2A | Agent-to-Agent 协议,Agent 互联标准 |
| scaffold | Agent 循环的外壳(提示词 + 协议 + 护栏),区别于模型本身 |
| num_ctx | Ollama 方言参数,控制上下文窗口(默认 4096 易截断) |