吃透 AI Agent 开发 · 第 27 篇 · 第五章 · 规划与任务
模型文本、工具进度、后台通知和输入状态会同时变化,界面应该消费语义事件,而不是直接监听内部函数。
普通聊天页面只要显示用户一句、机器人一句。Agent 界面却像一个忙碌的新闻编辑部:模型正在持续发文字,工具刚开始读文件,另一个后台任务跑完了测试,上下文又接近上限。所有消息几乎同时到达。
如果核心循环直接到处 console.log,刚开始很省事,功能一多就会出现重复行、光标跳动、日志和正文混在一起。更稳的做法是让运行时发布事件,让不同界面自己决定怎样展示。
先定义发生了什么
事件描述事实,不描述具体 UI 动作。
type AgentEvent =
| { type: 'status'; value: 'thinking' | 'running-tool' | 'idle' }
| { type: 'text-delta'; text: string }
| { type: 'tool-start'; callId: string; name: string }
| { type: 'tool-progress'; callId: string; message: string }
| { type: 'tool-end'; callId: string; ok: boolean; summary: string }
| { type: 'usage'; inputTokens: number; outputTokens: number }
| { type: 'background-done'; taskId: string; preview: string }
| { type: 'error'; message: string }
tool-start 是事实;“在右侧弹一个绿色卡片”是界面选择。终端可能显示一行 spinner,网页可能渲染进度卡,日志适配器则写成 NDJSON。
Event Bus 不是为了炫技
一个最小总线可以非常简单:
type Listener = (event: AgentEvent) => void
class EventBus {
private listeners = new Set<Listener>()
subscribe(listener: Listener) {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
emit(event: AgentEvent) {
for (const listener of this.listeners) listener(event)
}
}
它的价值在于解耦。Agent Loop 不需要导入 React、Ink 或浏览器 API;测试可以订阅事件并断言顺序;将来增加语音界面时,也不用改工具执行逻辑。
但不要把所有内部变量都变成公共事件。事件一旦被多个消费者依赖,就成了接口。只发布界面、审计或诊断真正需要的稳定语义。
流式文本为什么难渲染
模型可能分三次输出:
第一次:```ts
第二次:const answer =
第三次: 42
在代码围栏闭合前,每次都重新解析整段 Markdown,页面可能把后续所有文字当代码;长回答还会重复做昂贵解析。
一种实用策略是把内容分成“已稳定前缀”和“仍在增长的尾部”:完整段落、已闭合代码块进入稳定区并缓存解析结果;最后一个未完成块使用轻量预览。等边界闭合,再转成正式 Markdown。
interface StreamingParts {
stable: string
pending: string
}
这比对每个 token 更新整个文档稳定,也更容易控制内存。
状态栏与正文要分开
“正在读取文件”“已用 12K token”“后台测试完成”是运行状态,不一定属于最终回答。把它们混进 assistant 文本,会污染会话历史,也让模型下一轮误以为这些是自己说过的话。
可以维护两份状态:
- transcript:用户、模型和必要工具消息,用于上下文和持久化。
- view state:spinner、进度、折叠面板、光标和临时通知,只属于界面。
同一个 tool-end 事件可以更新 view state,同时把经过裁剪的 tool result 写进 transcript,两条路径各司其职。
输入框也是状态机
多行输入、光标移动、历史召回、粘贴、命令建议和附件选择放在一起后,直接操作字符串很容易出错。输入层可以显式保存:
interface InputState {
value: string
cursor: number
historyIndex: number | null
suggestionOpen: boolean
composing: boolean
}
每个按键都是纯状态转换,终端差异只影响按键映射。这样退格、中文输入法和多行粘贴都能单独测试。
终端需要接受现实差异
ANSI 光标在独立终端工作良好,在某些 IDE 集成终端里却可能闪烁或错位。自动模式应该根据环境选择保守实现,并允许用户关闭高级光标。
宽度计算也不能只用 JavaScript 字符串长度。中文、Emoji 和组合字符的显示宽度不同。表格、裁剪和光标定位要基于 grapheme 与终端显示宽度,否则一遇到中文就错列。
慢消费者与事件风暴
模型每个 token 都发事件,界面又同步做 Markdown 解析,可能反过来拖慢模型读取。常见做法是批量合并短时间内的 text-delta,例如 16 到 50 毫秒刷新一次;工具开始和结束等关键事件仍然立即处理。
审计写盘也可以排队,但退出前要 flush。优化吞吐时,不能让最后几条关键事件悄悄丢失。
直接输出的代价
如果工具内部直接 console.log('done'):
- TUI 不知道这行属于哪个工具。
- Web UI 根本收不到。
- 测试难以断言。
- 输出可能插进正在流式渲染的 Markdown 中间。
工具应该报告结构化进度,最终由适配器选择是否打印。
用 Input State 做个小练习
用上面的 Event Bus 模拟以下顺序:
- status: thinking
- text-delta: “我先”
- tool-start: read_file
- tool-end: read_file
- text-delta: “给出结论”
- status: idle
写两个订阅者:一个生成纯文本日志,一个维护 { status, text, tools } 界面状态。验证二者消费同一事件,却得到不同输出。
Runtime Event 到 Input State 的小结
流式界面的关键不是某个 UI 框架,而是结构化事件和状态边界。核心循环发布事实,界面折叠成视图;稳定 Markdown 与增长尾部分开;transcript 与临时状态分开。
下一篇把并发扩大到多个 Agent。任务拆开以后,真正困难的不再是“能不能启动子 Agent”,而是谁知道什么、结果怎样回来、代码修改怎样避免互相踩踏。
把一轮交互录成可以回放的带子
界面问题经常只在“刚好那个顺序”里出现:模型还在吐字,工具已经结束,后台通知又插了进来。靠截图很难复现,靠事件日志却很直接。下面是一段刻意压缩过的回放数据:
{"seq":41,"type":"status","value":"thinking"}
{"seq":42,"type":"text-delta","text":"我先检查配置。"}
{"seq":43,"type":"tool-start","callId":"c7","name":"read_file"}
{"seq":44,"type":"background-done","taskId":"test-2","preview":"单测通过"}
{"seq":45,"type":"tool-end","callId":"c7","ok":true,"summary":"读取 86 行"}
{"seq":46,"type":"text-delta","text":"问题出在超时设置。"}
{"seq":47,"type":"status","value":"idle"}
同一份记录可以喂给三个消费者:transcript 投影只留下会影响下轮推理的消息,TUI reducer 维护 spinner、工具行和通知角标,纯文本记录器则按时间顺序输出摘要。这样一来,“界面看起来不对”会变成一个可重复的状态转换测试,而不是只能盯着终端碰运气。
flowchart LR
L["Agent Loop"] -->|"语义事件"| B["Event Bus"]
B --> R["Terminal Reducer"]
B --> T["Transcript Projector"]
B --> A["Audit Writer"]
R --> V["Ink / Web View"]
T --> M["下一轮模型消息"]
A --> F["可回放事件文件"]
这里有一个容易忽略的细节:事件顺序需要由生产者负责,消费者不能用“收到时间”猜业务顺序。异步写日志可能晚一点落盘,界面批量刷新也可能把四个 delta 合成一次,但 seq 不能倒退。后台任务与主循环属于不同事件源时,可以使用每个任务自己的局部序号,再由聚合层补上统一时间线。
Reducer 要能接住重复和迟到
现实中连接会重试,订阅者也可能重新挂载。同一个 tool-end 被消费两次时,工具列表不该多出两条;迟到的 tool-progress 出现在 tool-end 之后,也不该把“已完成”改回“执行中”。可以把这类规则写进 reducer,而不是散落在组件里:
function reduceTool(state: ToolView, event: AgentEvent): ToolView {
if (event.type === 'tool-end' && event.callId === state.callId) {
if (state.status === 'completed') return state
return { ...state, status: 'completed', summary: event.summary }
}
if (event.type === 'tool-progress' && state.status === 'completed') {
return state
}
return state
}
这不是说所有事件都必须“恰好一次”投递。终端 UI 更适合接受“至少一次投递,再由状态转换去重”,因为它比维护一套昂贵的分布式确认协议简单得多。真正不能重复的写文件、发消息等副作用,不应由界面事件触发,它们属于工具执行层。
沿着四个文件读实现
q-code 只是一个可选的实现样本。读源码时不要急着找“漂亮组件”,先看事实如何从事件走到状态,再看状态怎样被展示。
| 次序 | 源码位置 | 带着什么目的读 |
|---|---|---|
| 1. | src/terminal/events.ts |
看 TerminalEvent 如何约束公共语义,以及 InMemoryTerminalEventBus 如何只做发布订阅 |
| 2. | src/terminal/state.ts |
看 terminalReducer 怎样把事件折叠为 transcript、usage、工具状态和监视器状态 |
| 3. | src/terminal/markdown.ts |
看 Markdown 解析边界、表格上限和行内文本清理为何属于展示层 |
| 4. | src/terminal/agent-monitor.ts |
看后台 Agent 的排序、可见性、tail 输出和终止能力怎样从运行状态派生 |
一次有价值的故障注入是:把第 45 条 tool-end 复制一遍,再把第 43 条 tool-start 延迟到最后。检查 transcript 是否仍然干净、工具最终状态是否仍是 completed、日志是否保留异常顺序供排障。只要这三件事能分别成立,核心事件、持久记录和用户界面就没有绑死在一起。
Transcript 是事实流,组件树只是投影
终端列表中一行工具状态可能由 tool-start、十次 progress 和 tool-end 折叠而来。不要把渲染后的“✓ read_file 86 lines”再反向解析成会话消息;展示会丢 callId、失败类别和原始顺序。
Reducer 从事件得到 TerminalState,组件只读取 state。测试无需渲染 Ink 就能断言 transcript 数、活动工具、usage 和 monitor;组件测试只关心某个 state 怎样显示。换 Web UI 时沿用事件与 reducer 语义,不复制 Loop。
状态过大也要裁剪。transcript UI 可只保留最近 400 项,历史仍在 SessionStore;高频成功 grep 结果折叠摘要,不删除模型账本。视图优化不改变数据原件。
Markdown 渲染器要允许“不完整”
模型正在输出表格时,分隔行尚未到达;代码围栏刚打开,语言名可能只收到一半。每个 delta 全量 parse 容易闪烁,也会把 pending 文本误判。
可以扫描最后一个稳定块边界:已闭合段落、列表、代码块进入 stable cache,尾部用轻量 plain text 或容错 parser。新 chunk 到达只重新解析 pending;边界闭合时合并进缓存。
Markdown parser 对表格设置最大行数,对超长行截断显示但保留原文句柄,防止模型输出构造巨大布局。ANSI 和 HTML 默认按文本处理,不能让模型内容控制终端。
宽度变化时只重排展示,不修改 transcript。中文宽字符、Emoji、组合字符使用显示宽度计算,不能用 string.length 定位光标和表格列。
输入状态机要处理中文输入法和粘贴
终端 keypress 不总是一个字符。IME composition 中按键用于选择候选,不应触发 slash suggestion;粘贴多行可能瞬间到达几千字符;光标移动要按 grapheme,不把一个 Emoji 拆开。
InputState 区分 editing、composing、history、suggesting、attachment-picker。纯 reducer 接收 insert-text、move-cursor、composition-start/end、submit 等事件,终端适配器负责把不同平台按键转成语义。
输入历史召回不应该立刻提交,用户还能编辑;以空格开头或命中 secret pattern 的输入不持久化。Q_CODE_HISTORY_SCOPE 决定项目、全局或两者,但当前输入 state 不依赖存储实现。
Ctrl+C 的含义取决于当前焦点
空输入且 Agent idle 时,Ctrl+C 可以退出;输入有内容时第一次清空;模型请求运行时发送 abort;Agent Monitor 打开时可能关闭面板或终止选中任务。若所有分支在不同组件里直接 process.exit,行为会互相打架。
键盘事件先经过焦点与运行状态解析,产出 clear-input、cancel-run、close-modal、request-exit 等 command。真正退出由 runtime 统一执行,先 flush 会话和审计,清理终端模式。
同样,Shift+Tab 切 Plan Mode、Ctrl+A 打开 SubAgent Monitor,都不应写进 Loop。它们改变 UI 或提交 payload,核心运行时只看到明确 mode/action。
后台 Agent Monitor 显示的是观测,不是控制真相
Monitor 从 registry 快照生成列表,running 优先,completed 默认隐藏。选中某项后读取 tail artifact,最多保留固定字节和行数,避免一个子任务输出撑满终端。
Kill 按钮只有 running/queued 状态可用,点击后显示 killing,直到运行时确认 killed。UI 不能因为按钮按下就本地删除任务;completed 清理只移除展示记录或按明确策略清理 artifact。
主 Agent 忙碌等待时,短提示说明 Ctrl+A 可查看,不把整个监视器自动铺满回答区。状态栏和正文保持不同空间,用户才能继续阅读最终内容。
性能快路径要有退出条件
普通纯文本行可以绕过完整 Markdown parser,解析结果用 LRU 按内容和宽度缓存。缓存 key 缺宽度会在窗口缩放后复用错误布局;缺主题则可能颜色不对。
优化前先 profile。若卡顿来自每 token setState,批量 delta 比换 parser 更有效;若来自 300 行表格,限制可见行并提供展开;若来自代码高亮,只对闭合代码块运行。
快路径必须与完整 parser 语义一致。用同一组 Markdown fixture 比较 block tree,遇到链接、强调或代码围栏就退出快路径,不为了速度错误显示。
非 TTY、管道和 CI 需要降级
输出被重定向到文件时,不应该发 ANSI 光标移动和 spinner 帧。Runtime 检测 TTY,选择 classic/stream adapter;事件仍相同,适配器只输出稳定文本、工具摘要和最终退出码。
IDE 集成终端对 ANSI 光标支持不一致,auto 模式可选择 inline 块光标,用户也能设 ansi/inline/off。降级不影响输入内容和会话,只牺牲部分视觉效果。
事件协议可以做快照回放
录制一轮脱敏 TerminalEvent JSONL,用 reducer 从空状态回放,最终 state 应稳定。把事件分成随机批次、重复可幂等事件、模拟窗口 resize,最终 transcript 与工具终态仍一致。
组件层再用桌面和窄终端截图检查:长中文标题不覆盖状态栏,表格可裁剪,monitor 不挡输入,错误提示不与 spinner 重叠。逻辑回放验证状态,截图验证布局,两种测试各自找不同问题。
可访问性从语义事件开始
spinner 每帧变化若被屏幕阅读器朗读,会产生噪声。关键状态用节流后的文本通知,颜色之外同时使用符号和词语表达成功/失败;终端主题保证对比度,高亮关闭后仍能读懂。
快捷键不是唯一入口,Slash 命令可完成会话切换、模式选择和任务查看。UI 不需要在正文里写长篇使用说明,但交互控件要有稳定 label 与 help。
流式体验的成熟标准不是动画顺滑,而是用户始终知道:现在发生什么、哪些内容是最终回答、怎样取消、后台工作在哪里、窗口变化后内容仍可读。事件边界把这些体验建立在可测试事实之上。