数据截至 (上游 commit 0004b748b71c)
会话主循环:一次对话到底转了几圈
30 秒导读: 你在 Kilo Code 里敲一句话回车,模型可能读文件、改代码、跑命令,来回十几轮才停。这一章讲的就是"这十几轮是谁在转、怎么转、什么时候停"。核心是
packages/opencode/src/session/prompt.ts:1466的runLoop—— 一个while (true),每转一圈 = 一次模型调用 + 若干工具执行。
本章是这本文档的主线章。上一章 运行骨架 讲了服务图和 SQLite,本章讲这些零件怎么被一个循环串起来;工具的具体实现见 工具层与权限闸门,上下文超了怎么砍见 上下文经济学。
1. 先建立直觉:循环,不是请求
一句话直觉
把一轮对话想成"打乒乓",不是"寄快递"。
寄快递是一来一回:你发请求,服务器回结果,结束。Kilo Code 是打乒乓:模型说"我要读这个文件",运行时把文件内容打回去,模型再说"我要改这一行",运行时改完再打回去 ……直到模型说"我说完了"。
每一次来回,Kilo Code 都做同一件事:
- 从数据库把这个会话的全部消息重新读一遍
- 判断"该停了吗"
- 没停就组一个新 prompt,发给模型
- 把模型的流式输出落成数据库里的 part
- 回到第 1 步
第 1 步是关键的反直觉点:循环不维护内存里的消息数组,每圈都从存储重新取(MessageV2.filterCompactedEffect,packages/opencode/src/session/prompt.ts:1486)。这样工具执行期间写进去的任何东西——子任务的产出、压缩摘要、用户中途插的话——下一圈自动看得到。
这一章会回答的问题
| 问题 | 在哪一节 |
|---|---|
| 谁能启动一轮对话 | §2 四个入口 |
| 循环每圈干了什么、什么时候停 | §3 runLoop 拆解 |
| 模型吐的 token 怎么变成数据库里的记录 | §4 SessionProcessor |
| 不同厂商的流式事件怎么统一 | §5 LLM 服务层 |
| 按 Esc 打断之后发生什么 | §6 中断与恢复 |
| subagent 是什么东西 | §7 子任务 |
| 网络抖了怎么办 | §8 重试与错误 |
2. 四个入口:谁能开启一轮
2.1 对外只有四个动词
SessionPrompt.Interface 一共暴露五个方法,四个能开启工作(packages/opencode/src/session/prompt.ts:139-150):
| 入口 | 白话 | 输入类型 | 定义位置 |
|---|---|---|---|
prompt | 用户发了一条消息 | PromptInput | prompt.ts:1308 |
loop | 不加新消息,直接接着转 | LoopInput | prompt.ts:1782 |
shell | 用户自己跑了条命令,记进会话 | ShellInput | prompt.ts:1811 |
command | 用户敲了个斜杠命令 | CommandInput | prompt.ts:1823 |
cancel | 打断 | — | prompt.ts:169 |
HTTP 层是一一对应转发的:handle("prompt") / handle("command") / handle("shell") / abort 都在 packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts:467-475。
2.2 它们最终都汇到同一个循环
prompt() command() shell() loop()
用户消息 /slash !bash 压缩后续跑
│ │ │ │
│ │ 展开模板 │ │
│ ▼ │ │
│ resolvePromptParts │ │
│ │ │ │
└────────┬────────┘ │ │
▼ ▼ │
写 user message shellImpl │
(createUserMessage) 单跑一条命令 │
│ 不调模型 │
▼ │
KiloSessionPromptQueue.enqueue ──────────────────────┤
(同会话串行排队) │
│ │
└──────────────► loop() ◄────────────────────┘
│
▼
state.ensureRunning
│
▼
runLoop() ← 本章主角
怎么读这张图: 从上往下是"谁触发"到"谁执行"。注意 shell 是唯一一条不进 runLoop 的路——它只是把一条用 户手动执行的命令记进会话历史,不调模型(packages/opencode/src/session/prompt.ts:599 shellImpl)。
2.3 prompt:把一句话变成一条消息 + 一次排队
prompt 做的事按顺序是(prompt.ts:1308-1358):
- 先恢复残局 ——
recoverDanglingAssistant/recoverProviderFinishError,见 §6.3 - 写 user 消息 ——
createUserMessage(prompt.ts:758)解析 parts、决定 agent 和 model - 清掉待答的问询 —— 新消息意味着旧的权限询问/建议已经过期,
Suggestion.dismissAll+question.dismissAll - 排队 ——
KiloSessionPromptQueue.enqueue,然后调loop
第 3 步的注释值得读,它说明了一个刻意不做的事:
// 真实源码 packages/opencode/src/session/prompt.ts:1334-1341(注释节选)
// Critically we never cancel the in-flight fiber here — that would abort the
// streamText call mid-tokens and cut off the assistant reply.
翻译: 用户在模型说话中途又发了一句,Kilo Code 不打断当前这次模型调用。它把新消息排到队列里,等这一步的 token 全部流完,再由循环内部的 hasFollowup 检查跳出去让新消息接管(§6.4)。
2.4 resolvePromptParts:@file、!bash、$1 的展开
用户输入的一行字不是纯文本,里面可能夹着引用。展开分两处:
(a)@ 引用 → file / agent part(resolvePromptParts,prompt.ts:176-270)
ConfigMarkdown.files(template) 扫出所有 @xxx,然后逐个判定:
// 示意,非源码 —— resolvePromptParts 的判定顺序
const alias = name.split("/")[0] // @docs/api.md → alias = "docs"
if (references.get(alias)) { // 命中配置的 reference 根目录
ensureContains(reference.path, target) // 越界就产出一段 problem 文本
push({ type: "file", url: ... })
} else if (fs.exists(resolve(worktree, name))) {
push({ type: "file", url: ... }) // 普通相对路径
} else if (agents.get(name)) {
push({ type: "agent", name }) // @reviewer 这种是切 agent
}
重点看越界检查:AppFileSystem.contains(reference.path, targetPath) 拦掉 @docs/../../etc/passwd 这类逃逸,不是抛错而是塞一段说明性文本进 parts(prompt.ts:213-224)。
(b)$1 / $ARGUMENTS / !`cmd` → 命令模板展开(command,prompt.ts:1837-1875)
四个正则定义在文件末尾(prompt.ts:2124-2128):
| 正则 | 匹配什么 | 展开成 |
|---|---|---|
placeholderRegex = /\$(\d+)/g | $1 $2 | 第 N 个参数;最后一个占位符吃掉剩余全部参数 |
"$ARGUMENTS" 字面量 | $ARGUMENTS | 整串原始参数 |
bashRegex = /!`([^`]+)`/g | !`git diff` | 命令的真实输出 |
argsRegex | 切参数 | 支持引号包裹和 [Image 1] 整体成一个 token |
!`cmd` 的执行走 CommandTimeout.texts(prompt.ts:1867),带超时保护。展开完的模板再喂回 resolvePromptParts,所以模板里也能写 @file。
还有一条分叉:如果命令绑定的 agent 是 subagent 模式,command 不产出文本 part,而是产出一个 subtask part(prompt.ts:1901-1913),交给 §7 的子任务通道。
3. runLoop:一圈到底做了 什么
这是全章的核心。函数签名在 packages/opencode/src/session/prompt.ts:1466,主体是 prompt.ts:1395 的 while (true)。
3.1 一圈的全貌
┌───────────────────────────────────────┐
│ ① 取消息(每圈都重新从 SQLite 读) │
└────────────────┬──────────────────────┘
▼
┌───────────────────────────────────────┐
│ ② 该退出了吗?(finish / 有无工具调用) │──── 是 ──▶ break
└────────────────┬──────────────────────┘
▼ 否
┌───────────────────────────────────────┐
│ ③ 有待办插队吗?子任务 / 压缩 / 溢出 │──── 有 ──▶ 干完 continue
└────────────────┬──────────────────────┘
▼ 无
┌───────────────────────────────────────┐
│ ④ 组 system + tools + 消息,调模型 │
│ handle.process(...) │
└────────────────┬──────────────────────┘
▼
┌───────────────────────────────────────┐
│ ⑤ 看返回值:continue / compact / stop │
└───────────────────────────────────────┘
│ │ │
continue compact stop
└──▶ 回 ① └──▶ 回 ① └──▶ break
怎么读: 从上到下是一圈的顺序,右侧箭头是出口。②③⑤ 是三个决策点,②⑤ 能终止循环,③ 是"先干别的再回来"。
3.2 ① 取消息:为什么每圈都重读
// 真实源码 packages/opencode/src/session/prompt.ts:1492-1494
let msgs = yield* MessageV2.filterCompactedEffect(sessionID)
msgs = KiloSessionPromptQueue.scope(sessionID, msgs) // kilocode_change
msgs = KiloSessionPrompt.trimBeforeLastSummary(msgs) // kilocode_change
三层过滤,各管一件事:
| 步骤 | 干什么 | 定义位置 |
|---|---|---|
filterCompactedEffect | 从最新往回读,遇到已完成的压缩点就停,并按 tail_start_id 重排保留尾部 | message-v2.ts:1134 filterCompacted |
scope | 藏掉"排在我后面"的用户消息,避免把还没轮到的 prompt 混进本轮 | prompt-queue.ts:84 |
trimBeforeLastSummary | 补 filterCompacted 的漏:手动 /compact 产生的摘要,其父消息是普通文本用户消息,没有 compaction part,前者切不掉 | kilocode/session/prompt.ts:388 |
第三条的注释直接点名了它修的 bug:不切的话,"reference session ended up re-shipping multi-MB base-64 images on every turn"(kilocode/session/prompt.ts:382-383)。
一个隐藏坑:数组顺序不等于时间顺序。 filterCompacted 为了适配模型消费,把消息重排成 [压缩用户消息, 摘要, ...保留尾部..., 继续用户消息]。所以取"最后一条用户消息"不能用 findLast,得按时间比较:
// 真实源码 packages/opencode/src/session/prompt.ts:1404-1405
const latest = KiloSessionMessageOrder.latest(msgs)
const { user: lastUser, assistant: lastAssistant, finished: lastFinished, tasks } = latest
KiloSessionMessageOrder.latest(kilocode/session/message-order.ts:21)先比 time.created,同毫秒再比 filterCompacted 重排前用 WeakMap 记下的原始下标(message-order.ts:12 compare、message-v2.ts:1158 annotate)。
3.3 ② 退出判定:三个条件缺一不可
基本退出条件(prompt.ts:1451-1470):
// 真实源码 packages/opencode/src/session/prompt.ts:1451-1457(简化排版)
if (
lastAssistant?.finish &&
!["tool-calls"].includes(lastAssistant.finish) &&
!hasToolCalls &&
lastAssistant.parentID === lastUser.id &&
userBeforeAssistant
) { break }
五 个条件读作:最后那条 assistant 消息,(1) 已经结束、(2) 结束原因不是"要调工具"、(3) 消息里也确实没有待处理的工具调用、(4) 它是回答当前这条用户消息的、(5) 用户消息在它之前。
其中 (4)(5) 是 Kilo 加的加固:多会话/排队场景下,"最后一条 assistant"可能属于别的轮次,光靠 ID 大小判断会错,所以改成比对 parentID + 按时间比较(prompt.ts:1414-1418)。
「为什么 stop 了还要继续转」——一个 provider 兼容补丁:
// 真实源码 packages/opencode/src/session/prompt.ts:1426-1428(注释)
// Some providers return "stop" even when the assistant message contains
// tool calls. Keep the loop running so tool results can be sent back to
// the model, but ignore cleanup-marked interrupted orphans.
有些厂商在返回工具调用时,finishReason 照样给 "stop" 而不是 "tool-calls"。如果只信 finish,循环会在工具结果还没回传给模型时就退出,对话卡死。所以加了 hasToolCalls 这条独立证据:
// 真实源码 packages/opencode/src/session/prompt.ts:1429-1432
const hasToolCalls =
lastAssistantMsg?.parts.some(
(part) => part.type === "tool" && !part.metadata?.providerExecuted && !isOrphanedInterruptedTool(part),
) ?? false
两个排除项各有来头:
providerExecuted—— 厂商自己在服务端执行的工具(如内置搜索),结果已经在流里了,不需要本地回传。isOrphanedInterruptedTool—— 打断后cleanup()把没跑完的工具标成{ status: "error", metadata.interrupted: true }(processor.ts:919-928)。这些是尸体不是待办,若算进hasToolCalls,循环会试图给一个永远回不来的工具补结果, 触发厂商的 assistant-prefill 校验失败。判定函数只有三行(prompt.ts:104-108)。
第三个出口:plan 模式的硬停。 若上一轮调用了 plan_exit 工具,循环在发起下一次模型调用之前弹一个确认给用户(prompt.ts:1435-1448),用户选"继续"才 continue,否则 break。判定逻辑在 kilocode/session/prompt.ts:48 shouldAskPlanFollowup。
3.4 ③ 待办插队:三种"先别调模型"
// 真实源码 packages/opencode/src/session/prompt.ts:1482
const task = tasks.pop()
tasks 来自 KiloSessionMessageOrder.latest:它取"最后一条已完成 assistant 之后"的所有用户消息里的 compaction 和 subtask part(message-order.ts:45-54)——也就是还没被处理的工作。
| 待办类型 | 做什么 | 做完 | 位置 |
|---|---|---|---|
subtask | 起一个子会话跑 subagent | continue | prompt.ts:1484-1487 |
compaction | 压缩历史生成摘要 | continue;返回 "stop" 则记错误并 break | prompt.ts:1489-1505 |
| 溢出自动压缩 | 上一轮 token 超了,自动排一次压缩 | continue | prompt.ts:1507-1530 |
第三种不是从 tasks 来的,是主动检查:compaction.isOverflow({ tokens: lastFinished.tokens, model })。它带一个熔断:
// 真实源码 packages/opencode/src/session/prompt.ts:1513-1526(简化)
const guard = KiloSessionPrompt.guardCompactionAttempt({ sessionID, attempts: compactionAttempts, closeReasons, message: lastFinished })
if (guard.exhausted) { /* 写错误、发事件 */ break }
compactionAttempts++
compactionAttempts 是每轮(不是每会话)的计数器,在 runLoop 入口置零(prompt.ts:1388),上限 3(kilocode/session/prompt.ts:337 MAX_COMPACTION_ATTEMPTS)。注释解释了为什么是 3:一次正常溢出压缩 + 一次"摘要自己也溢出"的重试,够了;再多就是死循环。
3.5 ④ 组装并调用
到这一步才真正准备模型请求。组装分四块:
(a)步数上限。 agent.steps 没配就是无穷(prompt.ts:1540-1541)。到达上限时,在消息尾部追加一条 assistant 消息,内容是 max-steps.txt——一段"工具已禁用,只准用文字总结"的硬指令(prompt.ts:1682)。
(b)工具集。 SessionTools.resolve 汇总内置工具 + MCP 工具 + agent 过滤(prompt.ts:1588-1604,实现在 session/tools.ts:26)。权限规则先过一道 guardPermissions(prompt.ts:1677):ask / plan / architect 这三个模式下,会话级的 allow 不能覆盖 agent 自带的限制(kilocode/session/prompt.ts:136-147)。
(c)system prompt。 三段拼接(prompt.ts:1650-1670):
// 真实源码 packages/opencode/src/session/prompt.ts:1670
const system = [...env, ...instructions, ...(skills ? [skills] : [])]
(d)体积止损。 序列化后超过 REQUEST_PRUNE_BYTES = 1_250_000(约 1.25 MB,prompt.ts:98)就触发一次持久化裁剪,然后把 ①②③ 那套过滤重跑一遍(prompt.ts:1656-1668)。这段代码是全函数唯一一处"重复自己"的地方,因为裁剪改了数据库,内存里的 msgs 就过期了。
组好之后是唯一一次外呼:
// 真实源码 packages/opencode/src/session/prompt.ts:1673-1686(字段节选)
const result = yield* handle.process({
user: lastUser, agent,
permission: KiloSessionPrompt.guardPermissions({ agent, session }),
sessionID, parentSessionID: session.parentID,
system,
messages: [...modelMsgs, ...(isLastStep ? [{ role: "assistant", content: MAX_STEPS }] : [])],
tools, model,
toolChoice: format.type === "json_schema" ? "required" : undefined,
})
3.6 ⑤ 按返回值分流
handle.process 只返回三个值之一(processor.ts:41 Result),但 runLoop 在看这三个值之前先做了几层特判。完整的优先级:
| 顺序 | 条件 | 动作 | 位置 |
|---|---|---|---|
| 1 | 拿到了结构化输出(StructuredOutput 工具被调用) | break | prompt.ts:1688-1693 |
| 2 | 要求 json_schema 但模型没给 | 写 StructuredOutputError 后 break | prompt.ts:1697-1704 |
| 3 | finish === "error" 且无 error 对象 | 补错误、标记 closeReasons = "error",break | prompt.ts:1706-1711 |
| 4 | result === "stop" | break | prompt.ts:1716-1719 |
| 5 | result === "compact" | 过熔断,排一次压缩,落到 6 | prompt.ts:1721-1744 |
| 6 | 队列里有更新的用户消息 | closeReasons = "interrupted",break | prompt.ts:1749-1752 |
| 7 | 非 compact 且 finish 仍为空 | 补成 "unknown" 并落库,然后 continue | prompt.ts:1764-1767 |
第 7 条是另一个 provider 兼容补丁,注释写得很直白(prompt.ts:1754-1763):有的厂商流结束了却不给终止原因(Anthropic 风格的 message_delta 带 stop_reason: null 后面直接 message_stop)。finish 为空 → 下一圈 ②的退出判定第一个条件就不成立 → 永远转下去。所以兜底写死 "unknown",让退出判定能生效;而 ② 里对 "unknown" 的处理是"只要没工具调用就允许退出"(!["tool-calls"].includes(finish) 对 "unknown" 为真)。
循环结束后只做两件事(prompt.ts:1778-1779):后台 fork 一次裁剪,然后返回最后一条 assistant 消息。
4. SessionProcessor:把 token 流变成数据库记录
4.1 它解决的小问题
模型返回的是一串增量事件:text-delta 一个字一个字来,tool-input-delta 一段 JSON 一 段 JSON 来。但 UI 要实时显示、崩溃要能恢复、下一圈要能重读——这些都要求落库。
SessionProcessor 就是这个转换器:事件流进去,MessageV2.Part 出来,边流边写。
4.2 Handle:一次模型调用的句柄
processor.create(...) 返回一个 Handle(processor.ts:43-66),四个成员:
| 成员 | 用途 |
|---|---|
message | 本次的 assistant 消息对象(getter,读的是可变的 ctx.assistantMessage) |
process(streamInput) | 真正跑一次流,返回 Result |
updateToolCall / completeToolCall / metadata | 给工具执行方回写状态用 |
compactError() | 读出本次是否遇到上下文溢出(Kilo 加的) |
内部状态集中在 ProcessorContext(processor.ts:90-104):正在写的文本 part、reasoning 映射表、活跃的 tool call 表、快照 id、needsCompaction / blocked 标志。一个 Handle 对应一次模型调用,不跨圈复用。
4.3 事件 → part 的映射
handleEvent(processor.ts:395)是一个大 switch。核心映射:
| 事件 | 落成什么 | 关键动作 |
|---|---|---|
text-start / -delta / -end | 一个 text part | delta 走 updatePartDelta 增量写,text-end 时过一遍 experimental.text.complete 插件(processor.ts:841) |
reasoning-start / -delta / -end | 一个 reasoning part | 孤儿 delta(没有 start)直接丢弃(processor.ts:422) |
tool-input-start | 一个 pending 状态的 tool part | ensureToolCall(processor.ts:321)建 part + 建 Deferred |
tool-call | 同一个 part 转 running | 参数落库;触发 doom-loop 检查 |
tool-result | 同一个 part 转 completed | 图片附件先过 image.normalize 压尺寸,压不下去就在输出里注明"已省略 N 张图"(processor.ts:561-581) |
tool-error | 同一个 part 转 error | 若错因是权限/问询被拒,置 ctx.blocked |
step-start | 一个 step-start part | 顺手抓一次影子 git 快照 |
step-finish | 一个 step-finish part | 结算 token/cost、写 patch part、判断是否溢出 |
三段式的价值: 同一个 callID 对应同一个 part,状态从 pending → running → completed/error 就地迁移。UI 拿到 pending 就能先画出"正在调用 read 工具"的框,参数流完再填内容。
4.4 doom-loop 检测:模型卡碟了怎么办
模型偶尔会反复调用同一个工具、同样的参数,无限重复。检测很朴素(processor.ts:530-555):
// 示意,非源码 —— doom loop 判据
const recent = parts.slice(-3) // DOOM_LOOP_THRESHOLD = 3
const stuck = recent.length === 3 && recent.every(p =>
p.type === "tool" && p.tool === name &&
JSON.stringify(p.state.input) === JSON.stringify(input) // 参数逐字相同
)
if (stuck) askPermission("doom_loop") // 不是报错,是问用户
阈值常量 DOOM_LOOP_THRESHOLD = 3 在 processor.ts:38。注意它的处置方式:不是直接失败,而是走权限系统问用户(processor.ts:547-554),用户可以选"总是允许"继续跑。
4.5 process:流水线的五层管道
// 真实源码 packages/opencode/src/session/processor.ts:1003-1007
yield* stream.pipe(
Stream.tap((event) => handleEvent(event)),
Stream.takeUntil(() => ctx.needsCompaction),
Stream.runDrain,
)
外面还包了四层(processor.ts:1008-1058),从内到外:
llm.stream(...) ─── 事件流
│
├─ Stream.tap(handleEvent) 每个事件落库
├─ Stream.takeUntil(needsCompaction) 发现溢出立刻停流
│
┌──┴─────────────────────────────────────┐
│ Effect.onInterrupt → aborted=true │ 被打断
│ Effect.retry(SessionRetry.policy) │ 可重试错误退避重来
│ Effect.catch(halt) │ 不可重试 → 记错误
│ Effect.ensuring(cleanup()) │ 无论如何都收尾
└─────────────────────────────────────────┘
cleanup 是最重要的一层(processor.ts:875-938),它保证"流断在哪里,数据库都是自洽的":
- 补一个 patch part(如果有快照差异)
- 把没闭合的 text / reasoning part 补上
time.end - 等所有活跃 tool call 的
Deferred,每个最多等 250ms(processor.ts:909) - 还没结束的 tool part 一律标成
{ status: "error", error: "Tool execution aborted", metadata.interrupted: true }
第 4 步写下的 interrupted: true,正是 §3.3 里 isOrphanedInterruptedTool 要识别的那个标记——这里埋,那里挖。
4.6 三个返回值怎么定
// 真实源码 packages/opencode/src/session/processor.ts:1060-1062
if (ctx.needsCompaction) return "compact"
if (ctx.blocked || ctx.assistantMessage.error) return "stop"
return "continue"
| 返回值 | 什么时候 | runLoop 怎么办 |
|---|---|---|
compact | step-finish 判定 token 溢出(processor.ts:780-797),或 halt 收到 ContextOverflowError / 预检信号(processor.ts:940-956) | 排一次压缩再转一圈 |
stop | 用户拒了权限/问询/建议(ctx.blocked),或消息上有 error | 直接 break |
continue | 其余情况 | 回 ① 再转一圈 |
ctx.blocked 的赋值有个开关:ctx.blocked = ctx.shouldBreak,而 shouldBreak 取决于配置 experimental.continue_loop_on_deny(processor.ts:988)。默认拒绝权限就停;开了这个开关,拒绝之后循环继续跑,让模型换个法子。
5. LLM 服务层:把厂商差异挡在门外
5.1 一句话职责
LLM.Service 只做一件事:吃一个 StreamInput,吐一个 LLMEvent 流。 上游(processor)永远不知道底下是哪家厂商、走的哪条运行时。
// 真实源码 packages/opencode/src/session/llm.ts:61-63
export interface Interface {
readonly stream: (input: StreamInput) => Stream.Stream<LLMEvent, unknown>
}
StreamInput 的字段(llm.ts:41-55):system: string[] + messages: ModelMessage[] + tools + model + agent + permission + toolChoice + preflight。注意 system 和 messages 是分开的,因为不同厂商放系统提示的位置不一样。
5.2 两条运行时,一个出口
llm.stream(input)
│
▼
LLMRequestPrep.prepare 统一预处理
│ (auth/headers/参数/消息变换)
▼
┌─── 溢出预检 ───┐
│ 超阈值就抛 │ → PreflightError → processor 转 "compact"
└───────┬───────┘
▼
experimentalNativeLlm ?
╱ ╲
不开 / 不支持 开且支持
│ │
▼ ▼
ai.streamText(...) LLMNativeRuntime.stream
│ │
▼ │
LLMAISDK.toLLMEvents │
│ │
└────────┬───────────┘
▼
LLMEvent 流(统一)
怎么读: 从上到下,中 间的菱形是运行时选择。两条路最后汇到同一种事件。选择是逐请求的——同一个会话的不同调用可以走不同的路(llm/AGENTS.md 第 36 行如此描述这个网关)。
代码上,run 返回一个带 tag 的对象,stream 再按 tag 分流:
// 真实源码 packages/opencode/src/session/llm.ts:433-443(节选)
if (result.type === "native") return result.stream
const state = LLMAISDK.adapterState()
return Stream.fromAsyncIterable(result.result.fullStream, ...).pipe(
Stream.mapEffect((event) => LLMAISDK.toLLMEvents(state, event)),
Stream.flatMap((events) => Stream.fromIterable(events)),
)