第 8 章 · 01 control.Controller 传输无关架构 本节摘要:本节精读 这个 36800 行的大包——Reasonix 三前端共享的传输无关 Controller。REASONIX.md 给出的核心架构约束只有一句话:"One transport-agnostic sits behind every frontend"。Controller 持有 agent 运行循环和会话生命周期,接收命令(Send/Cancel/Approve/SetPlanMode/Compact/NewSession),把一切发生的事情——推理、工具调用、审批、回合完成——作为带类型的事件流吐给单个 。
本节摘要:本节精读
internal/control这个 36800 行的大包——Reasonix 三前端共享的传输无关 Controller。REASONIX.md 给出的核心架构约束只有一句话:"One transport-agnosticcontrol.Controllersits behind every frontend"。Controller 持有 agent 运行循环和会话生命周期,接收命令(Send/Cancel/Approve/SetPlanMode/Compact/NewSession),把一切发生的事情——推理、工具调用、审批、回合完成——作为带类型的事件流吐给单个event.Sink。终端 TUI、桌面 webview、HTTP/SSE 服务器都通过同一套命令/事件接口驱动 Controller,没有一个前端重新实现回合生命周期、取消或审批。本节的核心心法是 REASONIX.md 的另一句话——"Add behavior to the controller, not a frontend, so all three inherit it"(把行为加到 Controller 而不是前端,三者自动继承)。
内容来源:原项目源码
internal/control/controller.go(包注释)、REASONIX.md(Conventions 段),对照 SPEC §3 核心抽象精读;并回顾第 1-7 章契约主线。
⚠️ 注意:Controller 是 Reasonix 体积最大的核心包之一(36800 行,80+ 文件)。本节聚焦"传输无关"这条契约主线与 Controller 和 agent/provider/tool/plugin 的编排关系,不逐文件精读。
阅读完本节,你应当能够:
Reasonix 把这条架构约束写在 REASONIX.md 的 Conventions 段,而且是第二条(仅次于"Go 包单一职责"):
- One transport-agnostic `control.Controller` sits behind every frontend (chat TUI, HTTP/SSE serve, Wails desktop). Add behavior to the controller, not a frontend, so all three inherit it.
这段话拆开有三个关键约束:
"Add behavior to the controller, not a frontend"是后半句的操作指令——这是给贡献者的代码规范:你新加一个功能(比如 compact、checkpoint、planmode),应该加到 Controller 里,而不是只加到 TUI 或只加到桌面。否则另两个前端就享受不到,三前端行为就会漂移。
internal/control/controller.go 的包注释把这条契约翻译成代码:
// Package control is the transport-agnostic session driver. A Controller owns // the agent run loop and session lifecycle, takes commands (Send/Cancel/Approve/ // SetPlanMode/Compact/NewSession/…), and emits everything that happens — // reasoning, tool calls, approvals, turn completion — as a typed event stream to // a single event.Sink. // // The point is one orchestration layer behind every frontend: a terminal TUI, a // desktop webview, or an HTTP/SSE server each drive the Controller identically // (issue commands, render events) and none of them re-implement turn lifecycle, // cancellation, or approval. The Controller depends on no frontend. package control
注意最后一句——"The Controller depends on no frontend"(Controller 不依赖任何前端)。这是契约的执行面:依赖方向是单向的,前端 import control,control 永远不 import 前端。这一句话是整个第 8 章的地基。
💡 契约要点:REASONIX.md 的架构约束是"软契约"(写给人看),
controller.go的包注释是它的代码翻译。两者完全一致——One / transport-agnostic / three inherit。在 Reasonix 贡献代码时,这条注释就是评审标准:你的 PR 如果让 Controller 依赖了前端库,会被打回。
Controller 的对外接口是一个典型的"命令进、事件出"双流模型。这是 transport-agnostic 的关键——任何能发命令、能收事件的前端都能用。
包注释列出的命令动词:Send / Cancel / Approve / SetPlanMode / Compact / NewSession。它们覆盖了一个会话的全部生命周期:
| 命令 | 作用 | 谁会发 |
|---|---|---|
Send |
发一句话,开一个新回合 | TUI 回车、桌面发送框、HTTP POST |
Cancel |
取消当前回合(中断 agent) | TUI Ctrl+C、桌面停止按钮、/stop 命令 |
Approve / Deny |
批准或拒绝一个待审批工具调用 | TUI 按键、桌面按钮、IM /approve 命令 |
SetPlanMode |
切换计划模式开关 | 桌面开关、配置默认值 |
Compact |
主动压缩上下文 | 用户手动触发或自动触发 |
NewSession |
开新会话 | /new 命令、桌面新建按钮 |
关键观察:这些命令是传输无关的语义动词,不带任何传输层信息。比如 Send 不带"这是 HTTP 请求"还是"这是终端按键",Approve 不带"这是飞书按钮回调"还是"这是桌面点击"。Controller 只关心命令的语义,不关心它从哪来。
Controller 把一切发生的事情作为带类型的事件吐给单个 event.Sink:
reasoning(模型推理片段) tool_call(工具调用开始) tool_result(工具调用结束) approval_request(等待用户审批) turn_complete(回合完成) ... 等
event.Sink 是一个接口(第 7 章已经见过类似设计),任何实现了它的对象都能接事件。TUI 把事件渲染成终端字符,桌面把事件渲染成 React 组件,SSE 服务器把事件序列化成 SSE 数据帧推给 HTTP 客户端。同一个事件流,三种渲染。
这就是 transport-agnostic 的工程实现——Controller 对外暴露"命令动词 + 事件流"两个抽象,完全不涉及渲染细节。前端的工作只剩两件:把用户的输入翻译成命令,把事件翻译成像素。
💡 契约要点:命令/事件双流是 transport-agnostic 的核心机制。命令是语义动词不带传输信息,事件是带类型的数据结构不带渲染指令。Controller 是一个"语义边界"——传输语义(终端/HTTP/桌面)在边界外,会话语义(回合/工具/审批)在边界内。
"行为加到 Controller 而不是前端"不是一句口号,它带来三个可量化的工程收益。
如果一个功能(比如 checkpoint 回放、上下文 compact、planmode 计划审批)写在 TUI 里,那 HTTP/SSE 用户和桌面用户都用不上。Reasonix 的规则是:所有跨前端共享的会话行为必须写在 Controller 里。
反例:很多 coding agent 把"取消当前回合"的实现写在 Ctrl+C 信号处理里——结果 HTTP 客户端没法取消,IM 用户也没法 /stop。Reasonix 把取消写成 Cancel 命令,Controller 内部处理"中断 agent 运行循环 + 释放资源 + 标记回合未完成"这些复杂逻辑,前端只需要在合适的时机发 Cancel。
当 compact 算法升级(第 6 章的 Context Engine v2),只要改 Controller,三前端同时受益。当 planmode 加了"计划需要批准"的新流程(第 9 章),只要改 Controller,三前端立刻支持。这是"行为加到 Controller 三前端继承"的直接收益——升级一处,三处生效。
如果没有这层,三前端各自实现一遍会话逻辑,行为迟早漂移:TUI 的 compact 用 v1 算法,桌面用 v2 算法,SSE 还在用有 bug 的旧版本。用户从一个前端切到另一个会困惑"为什么行为不一样"。
这是最关键的长期收益。如果将来 Reasonix 要加第四个前端(比如 VS Code 扩展的 ACP 协议、IM 机器人作为前端、Slack 集成),不需要重新实现任何会话逻辑——只要把新前端的事件渲染做好,把新前端的输入翻译成命令,接到 Controller 上即可。
实际上 internal/acp(第 8 章第 2 节会讲)就是这么接进来的——ACP 协议是一个 stdio JSON-RPC 适配层,它把 ACP 协议的请求映射到 Controller 命令,把 Controller 事件映射成 ACP 通知。ACP 包本身不实现任何 agent 逻辑,只是个翻译器。这就是"新前端零成本接入"的真实案例。
⚠️ 注意:有些行为确实是前端专属的,不应该加到 Controller——比如 TUI 的终端配色、桌面的窗口布局、SSE 的 HTTP 头。判断标准是"这个行为是否跨前端共享"。会话语义(回合/工具/审批/压缩)是共享的,UI 表现(颜色/布局/传输头)是专属的。
Controller 不是孤立存在的,它是 Reasonix 整个编排层级的最上层。说清它和下面几层的关系,才能理解它"36800 行"在做什么。
┌─────────────────────────────────────────────┐ │ 前端层:TUI / SSE serve / Wails 桌面 / ACP │ 传输相关 ├─────────────────────────────────────────────┤ │ control.Controller(本节) │ 传输无关编排 │ - 持有 agent 运行循环 │ │ - 管理会话生命周期 │ │ - 命令/事件双流 │ ├─────────────────────────────────────────────┤ │ agent.Session + harness loop(第 5 章) │ 单回合执行 │ - 调 provider 生成 │ │ - 调 tool 执行 │ │ - 处理 permission │ ├─────────────────────────────────────────────┤ │ provider(第 3 章)/ tool(第 4 章) │ 接口注册表 │ plugin MCP(第 7 章)/ extension(第 7 章) │ 扩展层 └─────────────────────────────────────────────┘
Controller 在最上层,它编排下面所有层,但它本身不实现这些层的功能:
agent.Session 调(第 5 章)。agent 内部的工具调度调(第 4 章)。看 controller.go 顶部的 import 就能验证编排关系:
import ( "reasonix/internal/ablation" "reasonix/internal/agent" // 单回合执行 "reasonix/internal/autoresearch" "reasonix/internal/billing" // 计费 "reasonix/internal/capability" "reasonix/internal/checkpoint" // 检查点回放 "reasonix/internal/command" "reasonix/internal/config" // 配置 "reasonix/internal/event" // 事件流 "reasonix/internal/evidence" "reasonix/internal/extension" // Extension 协议 "reasonix/internal/extension/dispatch" // ... 更多 )
注意三个关键点:
agent 但没 import 任何前端包——证明 transport-agnostic 是真的,有 import 图为证。event——这是事件流的类型定义包,Controller 把所有发生的事情编码成 event.Event 吐出去。checkpoint/billing/extension——这些都是 Controller 编排的横切关注点,不是前端专属功能。把 36800 行归纳一下,Controller 实际在做这几类事:
这些职责没有一个属于"某个前端"——它们全是跨前端共享的会话语义。这就是为什么 Controller 必须传输无关。
💡 契约要点:Controller 是"编排者"不是"执行者"。它编排 agent(单回合)、provider(模型)、tool(工具)、plugin/extension(扩展)、checkpoint(回放)、billing(计费),自己不实现任何一个的具体逻辑。36800 行的体量来自编排逻辑本身的复杂度——会话生命周期、回合编排、审批流、上下文维护、子代理协调,每一项都是独立的工程模块。
最后讲一个容易被忽略的细节——Controller 也是"prefix-cache 友好"的执行者。
第 6 章讲过 REASONIX.md 的 Cache-first 契约:系统提示前缀必须字节稳定,新信息 ride the turn tail 追加到尾部。这个契约的实际执行在 control.Compose(第 6 章详述)——Controller 在每个回合开始时,把稳定的系统提示前缀和当前回合的尾部信息组合成完整的上下文。
这里的关键是:Compose 算法写在 Controller 里,而不是写在前端里。如果写在前端,三前端各自的 Compose 实现可能不一致,导致系统提示前缀的字节序列在三前端之间漂移,DeepSeek 的 prefix cache 在前端切换时就会失效。把 Compose 放在 Controller,保证三前端组装出的上下文字节一致,缓存命中率最高。
这是"Add behavior to the controller"在缓存层面的具体体现——缓存敏感的组装逻辑必须集中在 Controller,不能分散到三前端。REASONIX.md 把 Cache-first 和 transport-agnostic 放在相邻的条目,不是巧合——它们是同一条工程主线的两个侧面:集中控制才能保证字节稳定。
control.Controller sits behind every frontend"——单数、传输无关、三前端继承;controller.go 包注释是它的代码翻译,最后一句"The Controller depends on no frontend"是依赖方向的硬约束。下一节我们走进三个前端的真实实现——TUI 用 Charm(bubbletea/lipgloss/bubbles)画终端,HTTP/SSE serve 用 OpenAI 兼容网关,Wails 桌面用 Go 后端 + React 19 前端,再加上 ACP(Agent Client Protocol)的 VS Code 扩展集成。同样的 Controller,四种截然不同的前端形态。