写代码 + AI,会是什么形式?
- 在 ChatGPT/DeepSeek/Kimi 网页上提问,然后 copy 对话框里的代码去用?一次成功,或者手动改,或者把报错输到对话框让 AI 给新的代码?
- 在 IDE 里智能补全,几个字符,一片连续多行,或者不连续的多片多行?
- 在 IDE 侧边栏对话框(或命令行对话框)里描述一个任务要求,让 AI 自己完成开发、测试,直接交付可用的代码?
上面三种形态,就是过去两年里我经历的变化,不久前我用第三种形态(Trae + GPT 5.2 模型),两天狂写了上万行移动端的 C++ 单元测试代码。
而这种形态,就是 Coding Agent,基于 LLM 打造的 Agent,能独立完成编程任务,交付可用的代码。今天我们就来分析一个开源的 Coding Agent:OpenCode,对标 Claude Code,但开源且可以使用任意模型。
我们先回顾几个基本的东西,最后再分析 OpenCode 的核心流程。
Coding Agent 本质
- LLM 的本质:一个获取给定字符串的下一个单词的函数。
- 持续获取下一个单词,直到遇到结束符,就完成了任务:续写。
- 对 LLM 进行针对性的训练(SFT 或 RL),以及构造好给 LLM 的初始输入,就能让这个续写看起来是回答问题。
- 通过一些工具调用,让 LLM 能阅读、修改、调试代码仓库,加上针对性的训练,也能让上面的过程看起来是解决问题。
LLM 应用开发基本套路
LLM 应用开发,就是调用 LLM 的 sdk,最具有代表性(也是很多家都兼容的)就是 OpenAI 的了,下面是一个简单的例子:
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
// 你自己维护一份“消息历史”,每次请求都把它完整带上(服务端本身不替你记忆)
const messages = [
{ role: "system", content: "你是一个简洁的中文助手,回答不超过80字。" },
{ role: "user", content: "我在深圳,周末想去爬山,有什么建议?" },
{ role: "assistant", content: "你更偏好轻松路线还是强度大一些?是否需要地铁可达?" },
{ role: "user", content: "轻松点,地铁可达,顺便推荐带什么。" },
];
const resp = await client.responses.create({
model: "gpt-4o-mini",
// Responses API 支持把对话按 role/content 结构作为 input 传入
input: messages,
});
console.log(resp.output_text);
// 如果要继续多轮:把本次 assistant 输出 append 回 messages 再发下一次
messages.push({ role: "assistant", content: resp.output_text });
- 其实就是给 LLM 输入,拿到输出。
- 把历史消息都作为输入,就能实现多轮对话。
- LLM 不输出文本,而是 tool-call 信息,那我们的代码就去调用相应的工具函数,然后把结果添加到消息列表里,再调用一次 LLM,就实现了工具调用的支持。
Vercel AI SDK
我们也可以使用更强大的 Vercel AI SDK(OpenCode 就用了它):
- 统一的模型调用方式:文本生成、结构化输出、对话、多 provider 适配(OpenAI/Anthropic/Google/Azure 等,视你接入的 provider 包而定)。
- Streaming-first(流式优先):把模型输出以流(
ReadableStream)形式提供,改善首 token 延迟与交互体验。 - Tools / Function calling:把外部能力(检索、数据库、业务 API)以工具方式暴露给模型,支持“模型决定调用—服务端执行—结果回填—继续生成”的闭环。
- 更好的 DX:前端(如 React/Next.js)侧提供常用 hooks(例如
useChat),减少处理 SSE/流拼接/状态机的样板代码。
AI SDK 实现了工具调用框架,我们只需要把工具注册进去就行,由框架自动实现 LLM tool-call 消息解析、工具调用、tool-result 回填给模型,大大简化了我们的工作。
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: openai("gpt-4o-mini"),
messages,
tools: { // 这里注册工具
searchDocs: tool({
description: "在内部文档中检索",
parameters: ...,
execute: async ({ query }) => { // 工具函数体,会被框架调用
return [{ title: "xxx", snippet: "..." }];
},
}),
},
});
return result.toDataStreamResponse();
}
AI SDK 的核心数据结构是 Message 和 Part。
1) 为什么要有 message / part 两层
Vercel AI SDK 把“对话”抽象成一个 message 列表(按轮次组织),但一条 message 的内容不再只是一段字符串,而是由多个 parts 组成(按片段/事件组织)。这样做主要为了解决:
- 流式输出:一条 assistant 回复会不断增量到达(token-by-token),自然对应“多个 part 逐步追加”。
- 工具调用:同一条 assistant message 里既可能有自然语言,也可能有 tool call、tool result 等“非文本片段”。
- 多模态/富内容:图片、文件、结构化数据、引用等都可以是独立 part,而不是硬塞进字符串里。
可以把它理解为:
message= 对话中的一条“发言”(有角色/时间/ID/元信息)part= 这条发言里的一个“内容片段/事件”(文本、工具调用、工具结果、图片等)
2) message 的设计(UI 层 vs Model 层)
AI SDK 通常会区分两类 message:
2.1 UI/客户端用的 Message(更“富”)
用于 useChat() 之类的前端状态管理与渲染。常见字段形态(概念化):
type UIMessage = {
id: string;
role: "user" | "assistant" | "system" | "tool";
// 兼容老用法:把纯文本拼在 content 里(通常由 parts 派生/汇总)
content?: string;
// 新的富内容表达:一条消息由多个 parts 构成
parts?: Array<Part>;
createdAt?: Date;
// 允许挂额外元数据(实现里可能叫 data/annotations/metadata 等)
experimental_attachments?: unknown;
};
要点:
- UIMessage 往往同时保留
content与parts:content便于简单渲染/兼容旧代码parts承载真实语义(工具调用、结构化数据等)
2.2 传给模型的 Message(更“规范/可序列化”)
服务端调用模型时常用的是更接近“LLM 消息协议”的结构(概念化):
type ModelMessage =
| { role: "system"; content: Array<Part> | string }
| { role: "user"; content: Array<Part> | string }
| { role: "assistant"; content: Array<Part> | string }
| { role: "tool"; content: Array<Part> | string; toolCallId: string };
要点:
- 模型层的 message 更关注 可被 provider 转换(OpenAI/Anthropic/Google 等格式不一,SDK 做统一)。
tool角色通常用于把 工具执行结果回填给模型(并用toolCallId对应某次调用)。
3) part 的设计(一个可扩展的“内容片段”联合类型)
part 通常是一个 带 type 判别字段的 union(discriminated union),便于扩展与类型安全。下面是最典型的几类(概念化,字段名会随版本略有差异,但语义一致):
3.1 文本类
type TextPart = {
type: "text";
text: string;
};
用途:
- 普通自然语言输出/输入
- 流式时会不断追加新的
TextPart或对最后一个TextPart进行增量拼接(具体实现策略可能不同)
3.2 工具调用(tool invocation / tool call)
type ToolCallPart = {
type: "tool-call";
toolCallId: string;
toolName: string;
args: unknown; // 通常是 JSON object,且会被 schema 校验(如 zod)
};
用途:
- assistant 决定要调用某个 tool 时,先产出一个 tool-call part(可能也是流式逐步补全 args)
3.3 工具结果(tool result)
type ToolResultPart = {
type: "tool-result";
toolCallId: string;
toolName: string;
result: unknown; // 工具执行的返回值(可为 JSON)
isError?: boolean;
};
用途:
- 服务端执行 tool 后,把结果作为 part 回填到对话中
- 模型可基于 result 继续生成最终回答
toolCallId 是关键:用它把 “tool-call” 与 “tool-result” 配对,支持并发/多次调用。
3.4 数据/自定义片段(data / annotations)
type DataPart = {
type: "data";
data: unknown; // 例如检索命中的文档列表、引用、UI 指令等
};
用途:
- 不想让模型“读到”的 UI 元信息(或想单独渲染的结构化信息)
- 与 RAG 检索结果、引用标注、调试信息等配合
3.5 多模态(例如图片)
不同 provider 支持不同,SDK 用 part 统一表达(概念化):
type ImagePart = {
type: "image";
image: string; // 可能是 URL / base64 data URL 等
mimeType?: string;
};
4) 一个完整例子:同一条 assistant message 里既有文本又有工具调用
const messages: UIMessage[] = [
{
id: "u1",
role: "user",
parts: [{ type: "text", text: "帮我查一下今天深圳天气,并给出穿衣建议" }],
},
{
id: "a1",
role: "assistant",
parts: [
{ type: "text", text: "我先查询一下深圳今日天气。" },
{
type: "tool-call",
toolCallId: "tc_123",
toolName: "getWeather",
args: { city: "深圳", date: "2026-01-17" },
},
],
},
{
id: "t1",
role: "tool",
parts: [
{
type: "tool-result",
toolCallId: "tc_123",
toolName: "getWeather",
result: { temp: 18, condition: "阴", wind: "3级" },
},
],
},
{
id: "a2",
role: "assistant",
parts: [
{ type: "text", text: "今天深圳约 18°C、阴天、风力 3 级。建议穿薄外套…" },
],
},
];
你可以看到:
- 工具调用与结果不需要塞进纯文本,结构清晰、渲染也更容易。
- UI 可以对不同 part 做不同展示(例如 tool-call 显示“正在查询…”)。
Coding Agent 开发基本套路
Coding Agent 的实现,最核心的就是 ReAct(Reasoning and Acting)循环:让 Agent 在解决复杂任务时,把“推理”和“调用外部工具/环境执行”交替进行的范式。其核心是一个不断迭代的 Thought → Action → Observation → (repeat) 循环,直到完成任务或触发停止条件。
循环的三步:Thought / Action / Observation
-
Thought(思考/推理)
Agent 基于用户目标与当前上下文,判断“下一步需要什么信息/要做什么操作”,并形成计划性的推理步骤。 -
Action(行动/工具调用)
Agent 按约定格式发出一次“动作”,通常是 调用工具/函数(如搜索、代码执行、API、计算等),把任务推进到可验证的外部世界。 -
Observation(观察/结果回灌)
运行时环境执行该动作,把输出(例如:检索结果、编译错误、测试失败日志、API 返回等)作为 Observation 回传给 Agent;Agent 再把它纳入上下文,决定是否需要调整策略并进入下一轮 Thought。
在工程实现上:本质是一个“追加历史的 while 循环”
很多手写/框架实现里,ReAct Loop 在代码层面就是:读取历史 → 让模型产出下一步 → 执行工具 → 把结果追加回历史,循环往复。
关键状态通常是一个不断增长的 messages(或 trace)列表:Agent 视角里“世界”基本就体现在这份历史里。
coding agent 场景下,Observation 往往是什么
在“写代码/改代码/修 bug”的任务里,Observation 常见是:
- 代码执行输出、报错栈、lint 结果、编译失败信息、单测失败用例等(都属于“环境或工具返回的结果/反馈”)。
- Agent 会根据这些反馈回到 Thought,决定下一步是“改实现/补依赖/调整调用方式/缩小排查范围/换工具或策略”等。
何时停止循环(常见停止条件)
- 任务目标已达成:Agent 判断已获得足够信息并可给出最终答案/最终代码。
- 达到最大步数/迭代上限:为避免无限循环,通常设置最大步骤数,到达后强制结束。
OpenCode 核心流程
最后,我们来看一下 OpenCode 这个具体的 Coding Agent 的 ReAct 循环实现。
1) 内层:单次 LLM 调用的流式 ReAct 执行器(消费流事件并落库)
- 位置:
packages/opencode/src/session/processor.ts:45的SessionProcessor.create(...).process() - 作用:消费
LLM.stream()的fullStream事件流;把 text/reasoning/tool-call/tool-result 等事件写入 session parts;处理重试、权限拒绝、context overflow 等情况,最终返回continue/stop/compact。
2) 外层:按 step 推进的 session loop(调度任务 + 决定是否继续下一轮)
- 位置:
packages/opencode/src/session/prompt.ts:257的SessionPrompt.loop() - 作用:从 session 历史里取最新 user/assistant 状态,优先处理待办任务(
subtask/compaction),否则启动一轮内层 “LLM + tools”;根据返回值continue/stop/compact决定下一步。
内层 process(SessionProcessor.process)核心流程
位置:packages/opencode/src/session/processor.ts:45-400
- 调用
LLM.stream(streamInput)获取流式输出(processor.ts:53,LLM 实现在packages/opencode/src/session/llm.ts:46)。 for await消费事件并落库:text-*:构建并增量写入最终回答(processor.ts:279-326)。reasoning-*:构建并增量写入推理片段(processor.ts:62-101)。tool-*:维护 tool part 状态机:pending → running → completed/error(processor.ts:103-221)。start-step/finish-step:tracking snapshot/patch、写 usage/cost、触发 summary、并检测 overflow(processor.ts:225-277)。
- 防止重复调用同一工具形成死循环:检测最近 3 个 tool part 是否“同 tool + 同 input”,触发
doom_loop权限询问(processor.ts:143-168)。 - 异常与重试:可重试错误会更新
SessionStatus为 retry 并退避重试;不可重试则写入 message error 并发布事件(processor.ts:339-363)。 - 统一收尾:把未完成的 tool part 标为 aborted,写 assistant 完成时间;最终返回:
compact(需压缩)/stop(被拒绝或错误)/continue(进入下一轮)(processor.ts:378-400)。
外层 loop(SessionPrompt.loop)核心流程
位置:packages/opencode/src/session/prompt.ts:257-632
- 并发/重入:如果同一个
sessionID已在跑,会把当前调用挂到回调队列,等待正在跑的 loop 产出下一条 assistant 消息后再返回(prompt.ts:258-264)。 - 每轮 step:读取消息历史(
MessageV2.stream+filterCompacted),反向扫描拿到lastUser / lastAssistant / lastFinished,并收集compaction/subtask任务 parts(prompt.ts:274-290)。 - 退出条件:当最新 assistant 已以非
tool-calls/unknown的原因完成且领先于最新 user 时,直接退出(prompt.ts:294-301)。 - 优先处理任务:
subtask:手动执行TaskTool,将结果写成 tool part,并插入一条 synthetic user message 引导下一轮继续(prompt.ts:315-479)。compaction:调用SessionCompaction.process(...)(prompt.ts:481-492)。- 上下文溢出:检测
lastFinished.tokens,必要时创建 compaction 任务(prompt.ts:494-507)。
- 正常处理(进入内层 ReAct):
- 创建本轮 assistant message +
SessionProcessor(prompt.ts:518-546)。 resolveTools(...)把ToolRegistry/MCP工具包装成 LLM 可调用的 tools(prompt.ts:552-559,prompt.ts:641+)。- 对消息做插件 transform + “stay on track” 包裹(
prompt.ts:568-590)。 - 调用
processor.process(...),拿到stop/compact/continue后决定 break / 创建 compaction / 进入下一轮(prompt.ts:591-620)。
- 创建本轮 assistant message +
外层 loop 里 compaction/subtask 任务从哪来
外层 loop 反向扫描历史时,把 message 的 parts 里 type === "compaction" || "subtask" 当作“待办任务”收集(prompt.ts:287-290)。它们的来源分别是:
subtask:通常由SessionPrompt.command()在执行命令模板时生成:当目标 agent 为subagent(或 command 强制subtask=true)时,会把这次 command 写成一条 user message 的subtaskpart,并通过SessionPrompt.prompt()入库(prompt.ts:1562-1584)。compaction:由SessionCompaction.create()生成:新建一条 user message,并写入一个compactionpart(session/compaction.ts:195-223)。触发点包括:- 外层 loop 检测到 context overflow(
prompt.ts:494-507)。 - 内层
processor.process()返回"compact"(prompt.ts:612-619)。 - 手动 summarize API:
POST /session/:sessionID/summarize(server.ts:1163-1219)。
- 外层 loop 检测到 context overflow(
为什么 compaction 要先写入 compaction part,再在下一轮 loop 里 process
这不是“排队而已”,而是把 compaction 变成一个可持久化的待办任务,并提供“压缩边界锚点”:
MessageV2.filterCompacted()会在历史里寻找“已经完成的 compaction 锚点”,从而截断压缩前的长历史:它需要看到一条 user message,且该 message 的 parts 里含type: "compaction",并且存在对应的assistant summary + finish(message-v2.ts:587-602)。SessionCompaction.create()创建的正是这条锚点消息(session/compaction.ts:195-223);随后外层 loop 在下一轮把它当作lastUser,再调用SessionCompaction.process({ parentID: lastUser.id, ... })(prompt.ts:481-492),从而让 compaction 产出的 summary assistant message 的parentID指回这条锚点 user message(session/compaction.ts:104-112),满足filterCompacted的配对逻辑。- 这种“任务入库 → loop 消费”的模式也和
subtask一致,带来可恢复性:即使中途 abort/重启,只要任务 part 已经写入 session 历史,后续再次SessionPrompt.loop()仍能捞到并继续执行。
工具执行链路(Action/Observation 回灌点)
- 工具集来源:
ToolRegistry.tools(...)+MCP.tools()(在prompt.ts:686-814组装)。 - 执行时机:模型输出
tool-call事件后,由 AI SDK 调用对应 tool 的execute(...);tool 的结果以tool-result事件回到SessionProcessor.process并写入 session parts。 - 统一钩子:每次工具执行前后触发
Plugin.trigger("tool.execute.before/after", ...),便于扩展(prompt.ts:694-714)。