本文基于 OpenCode 源码,深入分析其 Agent 循环机制、提示词拼装策略、LLM 调用流程以及流式响应处理。
OpenCode 是一个 AI 驱动的开发工具,采用 monorepo 架构(bun workspaces + turbo),主要使用 TypeScript 编写。核心依赖包括:
- Effect — 函数式编程框架,用于组合副作用、依赖注入、错误处理
- AI SDK(Vercel) — 统一的 LLM 调用接口,支持多 provider
- SolidJS — 前端框架
- Hono — HTTP 框架
- Drizzle — 数据库 ORM
核心代码位于 packages/opencode/src/,其中与 Agent 直接相关的模块:
| 目录/文件 | 职责 |
|---|
agent/agent.ts | Agent 定义与配置 |
session/prompt.ts | 用户输入处理、Agent 循环、工具注册 |
session/llm.ts | LLM 调用封装 |
session/processor.ts | 流式响应事件处理 |
session/system.ts | System Prompt 生成 |
session/instruction.ts | 指令文件加载(AGENTS.md 等) |
session/compaction.ts | 上下文压缩 |
2. 用户输入后的完整链路
链接到标题
当用户在对话框输入内容后,完整的处理链路如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| 用户输入
│
▼
SessionPrompt.prompt() ← session/prompt.ts:1371
│
├── createUserMessage() ← 解析文本、文件、agent 附件
│ ├── resolvePart() ← 每个附件类型分别处理
│ ├── 保存 User 消息到数据库
│ └── 保存各 Part(text/file/agent/subtask)
│
└── loop() ← session/prompt.ts:1630
│
└── runLoop() ← while(true) 核心循环
│
├── 加载消息历史
├── 拼装 system prompt
├── 拼装 messages
├── 注册 tools
│
├── processor.process() ← 发送给 LLM,处理流式响应
│ └── llm.stream()
│ └── streamText() ← AI SDK
│
├── 模型返回了 tool-call?
│ ├── Yes → 执行 tool → 继续循环
│ └── No → break,结束
│
└── 最终返回 assistant 消息
|
3. 核心 Agent 循环:runLoop
链接到标题
runLoop() 定义在 session/prompt.ts:1400,是整个 Agent 系统的心脏。它是一个 while(true) 循环,每轮迭代执行以下步骤:
3.1 循环步骤详解
链接到标题
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| 第 1 步:从数据库加载消息历史(过滤掉已压缩的)
│
第 2 步:判断是否需要退出循环
│ 最后一条 assistant 消息已经 finish 且不是 "tool-calls" → 退出
│
第 3 步:step++
│
第 4 步:如果是第 1 步,异步生成 session 标题(不阻塞主循环)
│
第 5 步:获取 model(resolve provider + model ID)
│
第 6 步:处理 subtask(子 agent)或 compaction(上下文压缩)如果有的话
│
第 7 步:创建一条新的 Assistant 消息(空壳,等待 LLM 填充)
│
第 8 步:创建 Processor(处理 LLM 流式响应的处理器)
│
第 9 步:resolveTools() — 收集所有可用工具
│
第 10 步:拼装 system prompt(env + instructions + skills)
│
第 11 步:handle.process() — 发给 LLM,流式处理响应
│
第 12 步:根据结果决定 continue / break
|
3.2 退出循环的判断逻辑
链接到标题
prompt.ts:1440-1448 中的判断:
1
2
3
4
5
6
7
8
| if (
lastAssistant?.finish &&
!["tool-calls"].includes(lastAssistant.finish) &&
!hasToolCalls &&
lastUser.id < lastAssistant.id
) {
break // 退出循环
}
|
也就是说,只有当满足以下所有条件时才退出:
- 最后一条 assistant 消息有 finish 标记
- finish 原因不是
"tool-calls"(意味着模型还要继续调用工具) - 没有 pending 的 tool-call
- 用户消息在 assistant 消息之前(确保模型已经响应了用户)
4. System Prompt 的拼装机制
链接到标题
System Prompt 的拼装分两个阶段完成。
4.1 第一阶段:在 runLoop 中收集
链接到标题
session/prompt.ts:1568-1574:
1
2
3
4
5
6
7
| const [skills, env, instructions, modelMsgs] = yield* Effect.all([
sys.skills(agent), // → string | undefined
sys.environment(model), // → string[]
instruction.system(), // → string[]
MessageV2.toModelMessagesEffect(msgs, model),
])
const system = [...env, ...instructions, ...(skills ? [skills] : [])]
|
这三部分并行获取:
4.1.1 env — 环境信息
链接到标题
由 session/system.ts:48-62 生成,内容示例:
1
2
3
4
5
6
7
8
9
10
| You are powered by the model named claude-sonnet-4-20250514.
The exact model ID is anthropic/claude-sonnet-4-20250514
Here is some useful information about the environment you are running in:
<env>
Working directory: /root/codes/myproject
Workspace root folder: /root/codes/myproject
Is directory a git repo: yes
Platform: linux
Today's date: Thu May 07 2026
</env>
|
4.1.2 instructions — 指令文件
链接到标题
由 session/instruction.ts:149-162 生成。查找逻辑:
- 全局指令:
~/.config/opencode/AGENTS.md 或 ~/.claude/CLAUDE.md - 项目指令:从当前工作目录向上搜索
AGENTS.md → CLAUDE.md → CONTEXT.md,取第一个匹配 - 远程指令:配置中
instructions 字段里的 URL 列表
每个匹配文件生成一个字符串:
1
2
| Instructions from: /root/codes/myproject/AGENTS.md
[文件内容]
|
4.1.3 skills — 技能描述
链接到标题
由 session/system.ts:65-77 生成,如果 agent 的权限允许 skill 工具:
1
2
3
4
5
6
7
| Skills provide specialized instructions and workflows for specific tasks.
Use the skill tool to load a skill when a task matches its description.
Available skills:
- answers: USE FOR AI-grounded answers via OpenAI-compatible /chat/completions...
- bx: USE FOR web search, research, RAG...
- frontend-design: Create distinctive, production-grade frontend interfaces...
|
4.2 第二阶段:在 LLM.stream 中合并
链接到标题
session/llm.ts:103-115:
1
2
3
4
5
6
7
8
9
10
| const system: string[] = []
system.push(
[
...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
...input.system, // ← 上面收集的 env + instructions + skills
...(input.user.system ? [input.user.system] : []),
]
.filter((x) => x)
.join("\n"), // 用 \n 拼成一个完整字符串,作为 system[0]
)
|
关键点:所有内容被 .join("\n") 合并为一个字符串,放入 system[0]。
Provider prompt 的选择逻辑在 session/system.ts:19-33:
1
2
3
4
5
6
7
8
9
10
11
12
| export function provider(model: Provider.Model) {
if (model.api.id.includes("gpt-4") || model.api.id.includes("o1") || model.api.id.includes("o3"))
return [PROMPT_BEAST] // beast.txt
if (model.api.id.includes("gpt")) {
if (model.api.id.includes("codex")) return [PROMPT_CODEX] // codex.txt
return [PROMPT_GPT] // gpt.txt
}
if (model.api.id.includes("gemini-")) return [PROMPT_GEMINI] // gemini.txt
if (model.api.id.includes("claude")) return [PROMPT_ANTHROPIC] // anthropic.txt
// ... 其他模型
return [PROMPT_DEFAULT] // default.txt
}
|
4.3 最终发给 LLM 的 messages 结构
链接到标题
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| [
{
role: "system",
content: " // ← system[0],一个大字符串
[anthropic.txt 全文] ← provider prompt
\n
You are powered by... ← env
<env>...</env>
\n
Instructions from: /path/AGENTS.md ← instructions
[AGENTS.md 内容]
\n
Skills provide specialized... ← skills
- answers: USE FOR...
- frontend-design: Create...
"
},
{ role: "user", parts: [...] }, // ← 用户历史消息
{ role: "assistant", parts: [...] }, // ← 助手历史消息
...
]
|
5. LLM 调用的五种场景
链接到标题
5.1 主 Agent 循环(最核心)
链接到标题
调用位置:processor.ts:673 → llm.stream() → llm.ts:336 的 streamText()
传参:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| streamText({
temperature, // agent.temperature ?? 根据模型自动计算
topP, // agent.topP ?? 根据模型自动计算
topK, // 根据模型自动计算
maxOutputTokens, // 根据模型限制计算
tools, // 内置工具 + MCP 工具(几十个)
toolChoice, // 通常 undefined(auto),结构化输出时 "required"
messages, // [system 消息, ...历史对话]
model, // 包装后的 language model,带 middleware
headers, // session affinity、user-agent 等
providerOptions, // provider 特有参数(如 Anthropic 的 extended thinking)
abortSignal, // 取消控制
maxRetries: 0, // 默认不重试(processor 层面有 retry 逻辑)
experimental_repairToolCall, // 工具调用修复(大小写问题)
})
|
特点:有完整的 tools、完整的 system prompt、使用用户选定的大模型。
调用位置:session/prompt.ts:169-229
参数特点:
1
2
3
4
5
6
7
8
9
10
11
| llm.stream({
agent: titleAgent, // 专用 title agent,temperature: 0.5
small: true, // 标记为小请求(用 getSmallModel)
tools: {}, // 无工具!
system: [], // 无额外 system
model: smallModel, // 用小/便宜模型
messages: [
{ role: "user", content: "Generate a title for this conversation:\n" },
...历史消息(只取第一条真实用户消息之前的上下文)
],
})
|
Agent prompt(agent/prompt/title.txt)要求:
结果处理:从流中收集所有 text-delta,拼接后取第一个非空行,截断到 100 字符作为标题。
5.3 上下文压缩(Compaction)
链接到标题
调用位置:session/compaction.ts:445
参数特点:
1
2
3
4
5
6
7
8
9
10
| processor.process({
user: userMessage,
agent: compactionAgent, // 专用 compaction agent,权限全部 deny
tools: {}, // 无工具!
system: [], // 无额外 system(agent 自带 prompt)
messages: [
...历史消息(截断版,stripMedia=true,工具输出截断到 2000 字符),
{ role: "user", content: "Create/Update summary..." }
],
})
|
压缩 prompt 模板要求输出固定结构的 Markdown:
1
2
3
4
5
6
7
8
9
10
| ## Goal
## Constraints & Preferences
## Progress
### Done
### In Progress
### Blocked
## Key Decisions
## Next Steps
## Critical Context
## Relevant Files
|
压缩流程:
- 保留最近 N 轮对话(
tail_turns,默认 2 轮) - 把之前的消息发给 LLM 生成结构化摘要
- 摘要作为 assistant 消息保存,之后加载历史时跳过被压缩的部分
- 返回
"continue" 则循环继续,附带一条 "Continue if you have next steps" 的用户消息
5.4 自定义 Agent 生成
链接到标题
调用位置:agent/agent.ts:331-399
参数特点:
1
2
3
4
5
6
7
8
9
| generateObject({ // 注意:不是 streamText!用 generateObject
temperature: 0.3,
messages: [
{ role: "system", content: generate.txt },
{ role: "user", content: `Create an agent configuration based on: "${description}"` },
],
model,
schema: z.object({ identifier, whenToUse, systemPrompt }),
})
|
特点:
- 使用
generateObject 而非 streamText - 要求返回符合 JSON schema 的结构化对象
- LLM 返回
{ identifier, whenToUse, systemPrompt },直接解析为对象
5.5 Summary(会话摘要统计)
链接到标题
调用位置:session/summary.ts
注意:实际上不调用 LLM。它只做 diff 计算(通过 snapshot 对比),统计 additions/deletions/files 数量。真正的"摘要文本"是 compaction 场景生成的。
| 场景 | 调用方式 | 有 tools | 有 system prompt | 模型 |
|---|
| 主循环 | streamText 流式 | 有(全部) | 有(完整) | 用户选定的 |
| 标题生成 | streamText 流式 | 无 | title.txt | 小模型 |
| 上下文压缩 | streamText 流式 | 无 | compaction.txt | 当前模型 |
| Agent 生成 | generateObject | 无 | generate.txt | 默认模型 |
| Summary | 不调用 LLM | — | — | — |
6. 流式响应的解析与处理
链接到标题
所有 streamText 调用返回的都是 result.fullStream,一个异步可迭代的事件流。
AI SDK 的 fullStream 会产出以下类型的事件:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| // 流控制
{ type: "start" }
{ type: "finish" }
// 文本输出
{ type: "text-start" }
{ type: "text-delta", text: string }
{ type: "text-end" }
// 思考链(部分模型如 Claude、o1)
{ type: "reasoning-start", id: string }
{ type: "reasoning-delta", id: string, text: string, providerMetadata?: any }
{ type: "reasoning-end", id: string }
// 工具调用
{ type: "tool-input-start", id: string, toolName: string }
{ type: "tool-input-delta", id: string, text: string }
{ type: "tool-input-end", id: string }
{ type: "tool-call", toolCallId: string, toolName: string, input: any }
{ type: "tool-result", toolCallId: string, output: any }
{ type: "tool-error", toolCallId: string, error: any }
// 步骤控制(多步调用时)
{ type: "start-step" }
{ type: "finish-step", finishReason: string, usage: LanguageModelUsage, providerMetadata?: any }
// 错误
{ type: "error", error: any }
|
6.2 Processor 的事件处理
链接到标题
session/processor.ts 中的 handleEvent 函数(第 220-583 行)通过 switch-case 处理每种事件:
| 事件 | 处理逻辑 |
|---|
start | 设置 session 状态为 busy |
text-start | 创建 TextPart,写入数据库 |
text-delta | 追加文本到 currentText,触发 updatePartDelta(前端实时显示) |
text-end | 触发 plugin hook experimental.text.complete,更新 Part |
reasoning-start | 创建 ReasoningPart,记录 providerMetadata(如 Anthropic signature) |
reasoning-delta | 追加思考内容,触发增量更新 |
reasoning-end | 最终更新 ReasoningPart |
tool-input-start | 创建 ToolPart,状态为 pending |
tool-call | 更新状态为 running,检查 doom loop(见下文),触发权限检查 |
tool-result | 更新状态为 completed,记录输出 |
tool-error | 更新状态为 error,如果是权限拒绝则标记 blocked |
start-step | 记录 snapshot(用于后续 diff 计算) |
finish-step | 计算 token 用量和费用,记录 snapshot diff,检查上下文溢出 |
6.3 Doom Loop 检测
链接到标题
processor.ts:350-376 实现了 doom loop 检测——如果最近 3 次工具调用都是同一个工具、同一参数,就会触发权限询问:
1
2
3
4
5
6
7
8
9
10
11
12
| const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD) // 3
if (
recentParts.length === DOOM_LOOP_THRESHOLD &&
recentParts.every(part =>
part.type === "tool" &&
part.tool === value.toolName &&
JSON.stringify(part.state.input) === JSON.stringify(value.input)
)
) {
yield* permission.ask({ permission: "doom_loop", ... })
}
|
6.4 工具执行的异步模型
链接到标题
AI SDK 的 streamText 在检测到 tool-call 事件后,会自动执行工具的 execute 函数(在同一次流式调用中),然后产出 tool-result 事件。工具执行的结果直接在同一个流中返回,不需要客户端手动发起第二轮请求。
这意味着一次 streamText 调用可以包含多轮工具使用——模型可以在一个流中:输出文本 → 调用工具 A → 获得结果 → 调用工具 B → 获得结果 → 输出最终文本。每个工具调用周期由 start-step / finish-step 包裹。
7. Agent 体系
链接到标题
7.1 内置 Agent
链接到标题
定义在 agent/agent.ts:111-236:
| Agent | 模式 | 说明 |
|---|
| build | primary | 默认 agent,有完整的工具权限 |
| plan | primary | 规划模式,禁止编辑工具,只能写计划文件 |
| general | subagent | 通用子 agent,用于并行研究复杂问题 |
| explore | subagent | 快速搜索 agent,只允许只读工具(grep、glob、read 等) |
| compaction | primary (hidden) | 上下文压缩专用,所有工具都 deny |
| title | primary (hidden) | 标题生成专用,所有工具都 deny,temperature: 0.5 |
| summary | primary (hidden) | 摘要生成专用,所有工具都 deny |
7.2 Agent 配置结构
链接到标题
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| interface AgentInfo {
name: string
description?: string
mode: "subagent" | "primary" | "all"
native?: boolean
hidden?: boolean
topP?: number
temperature?: number
color?: string
permission: PermissionRuleset
model?: { modelID: string; providerID: string }
variant?: string
prompt?: string // 自定义 system prompt(如 explore agent 有专用 prompt)
options: Record<string, unknown>
steps?: number // 最大循环步数
}
|
7.3 用户自定义 Agent
链接到标题
用户可以在配置文件中定义自己的 agent,会与内置 agent 合并:
1
2
3
4
| for (const [key, value] of Object.entries(cfg.agent ?? {})) {
if (value.disable) { delete agents[key]; continue }
// 合并/覆盖配置...
}
|
7.4 动态 Agent 生成
链接到标题
通过 Agent.generate() 方法(agent.ts:331-399),可以让 LLM 根据用户描述动态生成一个新的 agent 配置,返回 { identifier, whenToUse, systemPrompt }。
在 session/prompt.ts 的 resolveTools() 函数(第 368-546 行)中,工具按以下顺序注册:
内置工具 — 从 ToolRegistry 获取,包括:
bash — 执行命令read / edit / write — 文件操作glob / grep — 文件搜索task — 子 agent 调用webfetch / websearch — 网络搜索skill — 技能加载todowrite — 任务追踪- 等等
MCP 工具 — 从 MCP(Model Context Protocol)服务获取的外部工具
结构化输出工具 — 如果用户请求了 JSON schema 输出格式,会注入 StructuredOutput 工具
工具注册后会经过权限过滤:
1
2
3
4
5
6
7
8
| // llm.ts:449-454
function resolveTools(input) {
const disabled = Permission.disabled(
Object.keys(input.tools),
Permission.merge(input.agent.permission, input.permission ?? []),
)
return Record.filter(input.tools, (_, k) => input.user.tools?.[k] !== false && !disabled.has(k))
}
|
每个 agent 有自己的权限规则(permission),定义了哪些工具允许/拒绝/需询问。例如 explore agent 只允许只读工具。
8.3 工具执行上下文
链接到标题
每个工具执行时接收一个 Context 对象:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| type ToolContext = {
sessionID: string
abort: AbortSignal
messageID: string
callID: string
agent: string
messages: MessageV2.WithParts[]
metadata: (val) => Effect // 更新工具调用元数据
ask: (req) => Effect // 权限询问
extra: {
model: Provider.Model
bypassAgentCheck: boolean
promptOps: TaskPromptOps
}
}
|
9. 循环退出与上下文管理
链接到标题
Agent 循环在以下情况退出:
- 正常完成:LLM 返回
finish 且原因不是 "tool-calls" → 正常退出 - 权限阻断:
processor.process() 返回 "stop"(权限被拒绝、用户拒绝等) - 上下文溢出:
processor.process() 返回 "compact",需要压缩上下文 - 达到步数上限:agent 配置了
steps 且 step >= maxSteps - 错误:LLM 调用出错、工具执行出错等
9.2 上下文压缩(Compaction)
链接到标题
当 token 用量超过模型的上下文窗口时(overflow.ts 判断),会自动触发压缩:
- 保留最近 N 轮(默认 2 轮)对话作为 “tail”
- 将之前的消息(“head”)发给 LLM 生成结构化摘要
- 摘要替代原始消息存入历史
- 后续加载历史时,通过
filterCompactedEffect 跳过被压缩的部分
9.3 工具输出裁剪(Prune)
链接到标题
compaction.ts:300-344 还实现了工具输出裁剪:
- 从最新消息向前遍历
- 保留最近约 40,000 token 的工具调用输出
- 更早的工具调用输出被替换为
[Old tool result content cleared]
OpenCode 的 Agent 系统可以概括为以下核心设计:
事件驱动的 Agent 循环:while(true) 循环 + 流式事件处理,每轮把 system prompt + 历史 messages + tools 发给 LLM,流式处理响应,遇到 tool-call 就执行工具然后把结果加回消息历史继续循环。
System Prompt 采用分层拼接策略:Provider 基础 prompt → 环境信息 → 指令文件 → 技能描述,全部合并为一条 system 消息。
同一套 llm.stream() + processor 机制服务于 5 种不同的调用场景,通过不同的 agent 配置(权限、prompt、工具)来区分。
每个 agent 有独立的权限规则,工具执行前会进行权限检查,doom loop 检测防止无限循环。
自动的上下文压缩和工具输出裁剪确保长对话不会超出模型窗口。
本文基于 OpenCode 1.14.40 源码分析,关键文件路径均已标注,可直接在源码中对照阅读。