从零搭建 Agent Harness 系列(十四)抽象 Reporter 与 Runtime:把 Agent Engine 变成可复用内核
前面几篇已经让 go-tiny-claw 具备了连续对话、流式输出、Ctrl-C 中断和工具审批能力。但如果继续把这些能力都写在 main.go 或 REPL 中,系统很快会遇到一个问题:Agent Engine 只能被 Terminal 使用。
一个真正可复用的 Agent Framework,至少需要把下面两个问题拆开:
1 | Agent 如何把状态和事件交给外部渠道? |
第一个问题由 Reporter 解决,第二个问题由 Runtime 解决。
本文对应当前仓库中 Reporter 和 Runtime 的抽象实现,重点不是增加一个新功能,而是把已经存在的功能重新放到正确的架构边界中。
一、为什么 Engine 不能直接依赖 Terminal
在最初的实现中,Terminal 入口大致负责下面所有事情:
1 | 读取 stdin |
这种实现可以快速跑起来,但它把三种完全不同的职责混在了一起:
1 | 输入:用户从哪里提交 Prompt |
如果下一步接入 HTTP、WebSocket 或飞书,直接复制 REPL 代码会产生多个问题:
- 每个渠道都要重新实现一遍任务生命周期。
- Engine 需要知道当前输出是 Terminal 还是消息卡片。
- Provider 产生的流式 Chunk 可能直接进入
fmt.Printf。 - Ctrl-C、HTTP 取消、飞书撤回实际上都是“取消任务”,却没有统一模型。
因此我们希望得到下面的依赖关系:
1 | Terminal / HTTP / 飞书 |
渠道负责适配输入和展示,Runtime 负责一次任务的生命周期,Engine 只负责完成 Agent Loop。
二、Reporter 是什么
当前 Reporter 定义在 internal/reporter/reporter.go:
1 | package reporter |
它表达的不是“如何打印文字”,而是 Agent 运行过程中对外发布的高层事件:
1 | OnThinking 开始思考 |
Engine 只调用这些方法,不关心具体实现:
1 | if rep != nil { |
这段代码既可以把事件打印到 Terminal,也可以把事件转换成 WebSocket 消息、HTTP SSE 事件或飞书卡片更新。
1. Reporter 不是 Logger
Logger 主要记录系统诊断信息,例如:
1 | Provider 请求耗时 2.4 秒 |
Reporter 面向的是 Agent 使用者,表达的是用户需要看到的过程:
1 | 正在思考 |
两者的生命周期和消费对象不同,不能用一个接口混合。Logger 可以写文件,Reporter 通常需要绑定当前会话和当前渠道。
2. Reporter 不应该读取输入
审批看起来也是用户交互,但它与 Reporter 的方向相反:
1 | Reporter:Agent → 渠道 |
如果 Reporter 同时读取 stdin,就会和 REPL 的 Prompt 读取、Terminal Approval 的输入读取互相竞争。因此当前架构把审批保留在 internal/approval,Reporter 只负责输出。
三、为什么 StreamReporter 要单独定义
流式文本不是一条完整消息,而是一系列增量事件。当前源码用另一个接口表达这个能力:
1 | type StreamReporter interface { |
这样设计有两个好处。
第一,原有 Reporter 实现不需要立刻支持流式输出。一个只支持完整消息的渠道仍然可以实现:
1 | type SimpleReporter struct{} |
第二,Engine 可以在运行时检查渠道是否支持流式能力。当前 internal/engine/stream.go 中的逻辑是:
1 | streamReporter, canStream := rep.(reporter.StreamReporter) |
如果 canStream 为 true,就把文本 Delta 实时交给 Reporter;否则只等待 StreamCompleted,再通过普通的 OnMessage 输出完整内容。
这是一种 Go 中常见的能力接口设计:基础接口保证最低能力,扩展接口表达可选能力。
四、TerminalReporter 如何实现两个接口
当前 Terminal 实现位于 internal/reporter/terminal_reporter.go:
1 | type TerminalReporter struct{} |
它同时实现普通 Reporter 和 StreamReporter:
1 | func (r *TerminalReporter) OnMessage(ctx context.Context, content string) { |
因此同一个 Terminal Reporter 可以处理两类响应:
1 | 非流式 Provider:OnMessage |
Engine 的调用方不需要判断具体 Provider 是哪一种,只需要判断 Reporter 是否有流式展示能力。
不过,这个实现还有一个生产化问题:它直接使用 fmt.Printf,而工具调用是并发执行的。多个 goroutine 同时输出时,文本可能互相穿插。后续应该把输出对象注入 Reporter,并在 Reporter 内部增加互斥或事件队列:
1 | type TerminalReporter struct { |
这样测试时也可以传入 bytes.Buffer,不必真的写 stdout。
五、Reporter 抽象如何接入 Engine
Reporter 抽取后,AgentEngine.Run 的参数不再属于 Engine 包,而是依赖独立的 reporter.Reporter:
1 | func (e *AgentEngine) Run( |
Engine 在关键阶段发布事件:
1 | if e.EnableThinking && rep != nil { |
1 | if rep != nil { |
1 | if rep != nil { |
流式文本则由 generate 负责消费 Provider 的 StreamEvent,再转发给 StreamReporter。因此事件链路是:
1 | OpenAI SDK Chunk |
Provider 不知道终端存在,Reporter 也不需要知道 OpenAI SDK 的 Chunk 结构。
六、Runtime 是什么
Reporter 解决了“事件发给谁”,Runtime 解决的是“任务如何运行”。当前 Runtime 位于 internal/runtime/runtime.go,先定义了一个可以被 Engine 实现的运行接口:
1 | type AgentRunner interface { |
AgentEngine 天然满足这个接口,因为它已经有同样签名的 Run 方法。Runtime 不需要依赖 *engine.AgentEngine 这个具体类型,只依赖 AgentRunner:
1 | type Runtime struct { |
构造函数也因此很简单:
1 | func NewRuntime(runner AgentRunner, session *ctxpkg.Session) *Runtime { |
这里的关键不是代码量,而是依赖方向发生了变化:
1 | 之前:REPL → AgentEngine |
未来可以为测试构造一个假的 AgentRunner,也可以为不同渠道创建不同 Runtime 适配层,而不需要让 REPL 了解 Engine 的细节。
七、Start 如何创建一条任务
当前 Runtime 的异步入口是 Start:
1 | func (r *Runtime) Start( |
这段代码完成了四件事。
1. Runtime 负责写入用户消息
以前是 REPL 直接调用 session.Append。现在 Prompt 由 Runtime 统一追加:
1 | Terminal Prompt |
这样 HTTP、飞书和 Terminal 都不会各自实现一套消息写入逻辑,也不会出现同一个 Prompt 被追加两次。
2. 每次 Start 都创建独立的 Context
Runtime 接收渠道传入的父 Context,然后创建当前任务自己的子 Context:
1 | runCtx, cancel := context.WithCancel(parent) |
父 Context 通常代表整个进程或请求;子 Context 代表这一轮 Agent Run。取消边界因此变成:
1 | 程序退出 / HTTP 请求结束 |
Engine、Provider 和支持 Context 的工具都可以沿着这条链路退出。
3. Runtime 用 Goroutine 隔离 Engine
如果在 Start 中同步调用 runner.Run,调用方就无法及时得到一个任务句柄,也无法同时处理取消信号。因此 Runtime 把 Runner 放进 goroutine,并立即返回 Task。
4. done 使用容量为 1 的 Channel
1 | done := make(chan error, 1) |
Runner 结束时把结果写入 Channel,调用方通过 Task.Done() 读取。容量为 1 很重要:即使调用方先响应 Ctrl-C、暂时还没有读取结果,Runner 也可以安全地把最终错误写进去,不会因为发送结果而永久阻塞。
八、Task 是一次运行的句柄
当前 Task 定义在 internal/runtime/task.go:
1 | type Task struct { |
调用方不需要接触 Runtime 内部的 Context 和 goroutine,只需要操作两个方法:
1 | task, err := rt.Start(ctx, prompt, rep) |
这就是任务控制面的最小模型:
1 | Start → 得到 Task |
未来还可以在 Task 上增加:
1 | ID() 返回任务 ID |
但第一步不应该过早设计一个复杂的任务对象。先把生命周期边界固定下来,再根据渠道需求扩展。
九、同步 Run 只是 Start 的便捷封装
Runtime 同时保留同步入口:
1 | func (r *Runtime) Run( |
这里不要再复制一套 Prompt 校验、Session 追加和 goroutine 逻辑。同步 Run 只是:
1 | Start |
这样同步调用方和异步调用方共享完全相同的行为,后续修复取消或错误处理时也只需要改一个地方。
十、REPL 改造后只剩渠道职责
当前 internal/cli/repl.go 定义了一个更窄的 Runtime 接口:
1 | type Runtime interface { |
REPL 自己只保存输入、输出、Runtime 和 Reporter:
1 | type REPL struct { |
本地命令由 REPL 处理:
1 | switch prompt { |
普通 Prompt 则交给 Runtime:
1 | func (r *REPL) runTurn( |
改造后的 REPL 不再知道:
1 | Engine 如何循环 |
它只知道用户输入、命令、信号和任务句柄。这就是渠道层应该拥有的复杂度。
十一、main.go 如何组装对象
完成抽象后,启动入口负责依赖注入:
1 | eng := engine.NewAgentEngine(llmProvider, registry, gate, false, false) |
组装关系是:
1 | OpenAIProvider ─┐ |
main.go 仍然负责组装具体实现,但不再让具体依赖渗透进 REPL。将来换成 HTTP 入口时,可以复用:
1 | rt := runtime.NewRuntime(engine, session) |
这就是 Runtime 抽象的实际价值:不是为了多创建一个 package,而是为了让不同入口复用同一条 Agent 执行链路。
十二、一次完整请求的时序
把 Reporter 和 Runtime 放在一起,一次请求的时序如下:
1 | 用户输入 Prompt |
Ctrl-C 的路径则是:
1 | Ctrl-C |
注意,Reporter 不参与取消。它只展示取消前已经产生的事件;取消控制权属于 Runtime 和 Task。
十三、这次抽象解决了什么
1. Engine 可以脱离 Terminal 测试
可以使用一个假的 Reporter 收集事件:
1 | type RecordingReporter struct { |
也可以使用假的 AgentRunner 测试 Runtime:
1 | type FakeRunner struct { |
不需要启动真实模型,也不需要读取 stdin。
2. 输出渠道可以替换
同一套 Engine 和 Runtime 可以连接不同 Reporter:
1 | TerminalReporter |
3. 任务控制统一
Terminal 的 Ctrl-C、HTTP 的请求取消、服务端的超时和未来的“停止任务”按钮,都可以映射到同一个操作:
1 | task.Cancel() |
4. 连续会话有了明确归属
REPL 不再直接维护消息写入规则,Runtime 负责把 Prompt 转换为 Session 中的一条 User Message,再启动一次 Agent Run。
十四、当前实现还不够生产级
目前的 Runtime 是一个最小可用抽象,还需要继续补齐以下边界。
1. 防止同一 Session 并发运行
当前 Start 可以被连续调用两次。两个 Runner 会同时修改同一个 Session,导致上下文顺序和 Reporter 输出不可预测。
Runtime 至少应该增加:
1 | activeTask |
2. 任务状态不能只靠 error
context.Canceled、超时、用户拒绝和 Engine 失败应该在外部有清晰区分。后续可以定义:
1 | type TaskStatus string |
3. Reporter 需要结构化事件
当前 Reporter 方法已经比 fmt.Printf 好很多,但仍然是多个回调。生产环境还需要统一事件模型:
1 | type Event struct { |
这样日志、UI、Trace 和远程渠道可以订阅同一份事件,而不必分别实现十几个回调。
4. 终端输入和审批输入需要统一协调
当前 REPL 读取 Prompt,Approval Handler 也读取 Terminal 输入。它们必须保证不会同时消费同一个 stdin。更进一步,可以由 Runtime 统一管理输入请求,并让审批变成一种挂起的任务状态。
5. Session 需要持久化
当前 Session 主要存在内存中。进程重启后,连续对话会丢失。生产级 Runtime 需要抽象:
1 | SessionStore |
然后根据部署方式选择内存、SQLite、PostgreSQL 或其他存储。
十五、下一步应该怎么做
Reporter 和 Runtime 抽象完成后,下一步不是继续给 Engine 增加更多 if,而是先补齐任务控制面:
1 | Runtime 单任务并发控制 |
等这些边界稳定后,Agent Framework 才能从“一个可以运行的 Terminal Demo”逐渐变成“多个入口共享的执行平台”。
Reporter 让 Engine 的输出可以被替换,Runtime 让 Engine 的生命周期可以被控制。两者结合之后,Agent 的核心能力终于不再绑定某一个终端入口,这也是从 Demo 走向 Framework 的关键一步。