
1. 从一次 Agent 卡死说起processor.ts 到底在编排什么如果你用 opencode 跑过稍微复杂点的任务大概率遇到过这几种情况模型连续三次调用同一个read工具读同一个文件然后整个会话像被点了穴一样停住或者上下文快撑爆的时候它突然自己开始总结然后接着往下干。这些行为背后都不是模型聪明而是packages/opencode/src/session/processor.ts这个文件在调度。processor.ts 是 opencode 的会话处理主循环你可以把它理解成 Agent 的大脑皮层——它不负责思考那是 LLM 的事它负责把 LLM 吐出来的流式事件翻译成一个个可执行的动作再把这些动作的结果喂回去。整个文件 718 行基于 Effect-TS 的纯函数式架构没有一处async/await。它要处理的事情包括接收 LLM 的流式事件流、协调工具调用从创建到完成或失败的完整生命周期、管理推理过程、处理文本增量输出、检测死循环、以及在上下文溢出时触发压缩。适合读这篇的人已经能跑通 opencode、想搞清楚 Agent 调度机制的中级开发者正在用 Effect-TS 写并发编排、想找个真实项目参考的人以及被工具调用卡住折磨过、想从源码层面定位问题的人。下面我会按问题场景 → 前置准备 → 可复制配置 → 验证请求 → 排错 → 延伸的顺序拆代码片段都能直接对着本地源码跟。2. 前置准备把 opencode 源码和 TaoToken 接入环境搭起来要复现 processor.ts 的调试你得先有一个能真实发起 LLM 请求的环境。opencode 本身是客户端它需要一个兼容 Anthropic 或 OpenAI 协议的模型服务端点。我这边习惯用 TaoToken 做接入层原因是它的 API 格式和主流 SDK 对齐改 base_url 就能接上不用动 opencode 的源码逻辑。先拿到 API Key打开 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentprocessor_tsutm_campaignrewrite 在控制台里创建一个 key复制出来。注意这个 key 只在创建时完整显示一次丢了就重新建。然后把 opencode 源码拉下来确认 processor.ts 的位置git clone https://github.com/sst/opencode.git opencode-dev cd opencode-dev ls packages/opencode/src/session/processor.ts你应该能看到这个文件。接着配置模型端点opencode 支持通过环境变量或配置文件指定 provider。最省事的方式是写一个本地配置把 base_url 指向 TaoToken 的 API 地址export OPENCODE_PROVIDERanthropic export ANTHROPIC_BASE_URLhttps://taotoken.net/api export ANTHROPIC_API_KEYsk-你的key注意ANTHROPIC_BASE_URL后面不要带路径后缀SDK 会自己拼/v1/messages。如果你用的是 OpenAI 兼容模式变量名换成OPENAI_BASE_URL和OPENAI_API_KEYbase_url 同样是https://taotoken.net/api。依赖装好之后先别急着改源码跑一次最小会话确认链路通bun install bun run packages/opencode/src/index.ts --model claude-sonnet-4-20250514 读一下 package.json 然后告诉我项目名如果它能正常调用工具并返回结果说明你的接入层没问题接下来读 processor.ts 才有意义。如果这一步就报 401 或连接错误先回到 API Keys 页面确认 key 状态别往下走。3. 可复制配置拆解 processor.ts 的 Effect 执行链processor.ts 的核心是SessionProcessor.process()它返回一个EffectResultResult 只有三个值compact | stop | continue。这个设计很关键——主循环不关心具体发生了什么只关心下一步该干嘛。先看它的整体管道结构这是你可以直接对照源码读的部分const stream llm.stream(streamInput) yield* stream.pipe( Stream.tap((event) handleEvent(event)), Stream.takeUntil(() ctx.needsCompaction), Stream.runDrain, ) if (ctx.needsCompaction) return compactStream.tap对每个事件做副作用处理更新上下文、执行工具Stream.takeUntil在检测到需要压缩时立刻中断流。这就是 Effect-TS 的表达力用组合子把处理事件和何时停止解耦不用手写 while 循环和 break 标志位。上下文状态集中在ProcessorContext里这是所有事件处理都在修改的中枢interface ProcessorContext extends Input { toolcalls: Recordstring, ToolCall // 活跃工具调用表 shouldBreak: boolean // 权限拒绝时中断 snapshot: string | undefined // 文件快照 blocked: boolean // 是否被权限阻塞 needsCompaction: boolean // 是否需要压缩 currentText: SessionV1.TextPart | undefined reasoningMap: Recordstring, SessionV1.ReasoningPart }工具调用的生命周期是五个函数的接力顺序不能乱ensureToolCall(id) // 创建或确认记录 ↓ updateToolCall(id, update) // pending → running ↓ 并行执行工具 ↓ completeToolCall(id, output) // 成功 failToolCall(id, error) // 失败权限拒绝时置 blocked ↓ settleToolCall(id) // 清理触发 Deferred 完成ensureToolCall里有个容易被忽略的双重检测某些 provider 会在发出 tool-call 事件之前就已经执行了工具所以代码要先readToolCall查一下记录是否存在存在就标记providerExecuted不存在才创建新的 ToolPart。这个分支如果你在调试时看到工具没执行但结果有了八成是走了 provider 预执行路径。死循环检测的阈值是硬编码的 3const DOOM_LOOP_THRESHOLD 3 const recentParts parts.slice(-DOOM_LOOP_THRESHOLD) if ( recentParts.length ! DOOM_LOOP_THRESHOLD || !recentParts.every((part) part.type tool part.tool value.name part.state.status ! pending JSON.stringify(part.state.input) JSON.stringify(input), ) ) return yield* permission.ask({ permission: doom_loop, patterns: [value.name] })连续三次相同工具加相同输入才会弹权限询问。注意它比较的是JSON.stringify(input)所以参数顺序不同会被判定为不同调用——这是个小坑后面排错会讲。错误处理是四层叠加写在process的 pipe 里yield* Effect.gen(function* () { // LLM 流处理 }).pipe( Effect.onInterrupt(() { aborted true; halt(error) }), Effect.catchCauseIf(/* 忽略纯中断 */), Effect.retry(SessionRetry.policy({ /* ... */ })), Effect.catch(halt), Effect.ensuring(cleanup()), )Effect.ensuring(cleanup())保证无论成功失败都会清理生成未完成文件的快照、关闭未完成的文本块和推理块、等待所有工具调用完成最多 250ms、把超时未完成的工具标记为interrupted、更新 assistantMessage 的完成时间。那个 250ms 是防止无限等待的关键调试时如果看到工具状态是 interrupted就是这里兜的底。4. 验证请求本地跑一次带工具调用的会话并观察事件流光读代码不够得让它跑起来。最直接的验证方式是在handleEvent里加一行日志观察事件类型的分发。找到handleEvent那个大 switch在入口处插入const handleEvent Effect.fn(handleEvent)(function* (event: Event) { console.log([processor] event:, event.type) switch (event.type) { // ... 原有分支 } })然后跑一个会触发工具调用的任务bun run packages/opencode/src/index.ts --model claude-sonnet-4-20250514 列出当前目录的文件然后读取 README.md 的前 20 行你应该能在终端看到类似这样的事件序列[processor] event: message_start [processor] event: content_block_start [processor] event: content_block_delta [processor] event: content_block_stop [processor] event: message_delta [processor] event: message_stop [processor] event: step-finish工具调用会穿插在content_block_start里type为tool_use。当工具执行完你会看到tool-result相关事件然后step-finish触发上下文检查。如果isOverflow返回 truectx.needsCompaction被置位Stream.takeUntil中断流process返回compact上层就会走压缩逻辑。想验证死循环检测可以故意让模型重复调用同一个工具。最简单的办法是写一个提示词诱导它反复读取 package.json直到我让你停。跑起来后观察第三次相同调用时是否弹出doom_loop权限询问。如果没弹检查recentParts的 slice 逻辑——它取的是MessageV2.parts的最后三条如果中间夹了文本 partevery就会失败检测不触发。验证上下文压缩可以塞一个超长文件进去bun run packages/opencode/src/index.ts --model claude-sonnet-4-20250514 读取 node_modules 下任意一个大于 500KB 的文件并总结当 token 用量超过模型窗口阈值step-finish里isOverflow会返回 true你会看到会话先总结再继续而不是直接报错退出。这就是compact返回值的实际效果。5. 本篇常见错排查工具卡住、死循环不触发、压缩不生效工具调用一直停在 running 状态。先看cleanup里的 250ms 超时有没有生效。如果工具本身是异步的但没正确返回 EffectsettleToolCall里的 Deferred 永远不会完成cleanup 超时后会强制标记interrupted。检查你的工具实现是不是漏了yield*或者返回了裸 Promise。死循环检测不触发。前面提过JSON.stringify(input)对参数顺序敏感。如果模型每次调用时参数顺序略有不同比如{a:1,b:2}和{b:2,a:1}字符串化结果不同every判定失败。另外确认recentParts里没有混入非 tool 类型的 part文本增量会打断连续性。上下文压缩后行为异常。needsCompaction是在step-finish里设置的但Stream.takeUntil的判定发生在事件流层面。如果你在handleEvent里抛了异常流会提前终止needsCompaction可能没来得及置位。用Effect.catchCauseIf确认纯中断被正确忽略而不是被当成错误吞掉。重试策略导致重复工具调用。Effect.retry(SessionRetry.policy(...))会在失败时重放整个 Effect 链。如果工具调用有副作用比如写文件重试可能造成重复执行。调试时把 retry 的times临时设为 0确认单次执行路径正常后再放开。provider 预执行导致工具记录重复。ensureToolCall的双重检测如果判断失误会创建两条 ToolPart。检查readToolCall的查询条件是否包含了正确的 message id 和 tool call id别用工具名去查。6. 延伸把 processor.ts 的编排思路用到自己的 Agent 上processor.ts 最值得抄的不是具体代码是它的分层思路用Stream.tap做事件副作用、用Stream.takeUntil做中断条件、用Effect.ensuring做兜底清理、用Result三值枚举把下一步决策从具体执行里剥离出来。这套结构换成任何流式 Agent 都成立。如果你想继续往下挖建议从两个方向入手一是把handleEvent的 15 种事件类型逐个打日志跑一遍搞清楚每种事件对ProcessorContext的修改二是把SessionRetry.policy的参数调出来观察不同重试策略下工具调用的行为差异。模型对话可以在 https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentprocessor_tsutm_campaignrewrite 里直接试对比不同模型在同样提示词下的事件流差异对理解调度逻辑很有帮助。如果你打算长期跑编码类 Agent 任务Coding Plan 那边有更完整的额度方案https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentprocessor_tsutm_campaignrewrite 。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentprocessor_tsutm_campaignrewrite 里面把 Anthropic 和 OpenAI 两种协议的字段映射写得很细改 base_url 之前先扫一遍能省不少调试时间。