Qwen Code ACP 消息改写中间件:用 LLM 把终端编码 Agent 的输出改写成业务友好语言
【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code
Qwen Code 在 ACP(Agent Client Protocol)集成中提供了一套可选的消息改写中间件(Message Rewrite Middleware):原始消息原样透传,同时把每个回合累积的思考与回复文本交给一个独立的 LLM 调用进行"降噪"改写,再以_meta.rewritten: true标记的新agent_message_chunk追加下发。读完本文,你将理解这套中间件的适用场景、messageRewrite配置项的完整含义、其在packages/cli/src/acp-integration/session/rewrite/下的源码实现(回合缓冲、LLM 改写器、中间件编排),以及下游渠道如何基于_meta.rewritten标记避免重复投递。
适用场景:垂直业务集成中的输出"降噪"
当编码 Agent 被接入数据分析、运维、报表生成等垂直业务场景时,原始输出里往往混杂着终端用户并不关心的技术细节:文件路径、工具调用名、内部推理过程、代码片段、堆栈跟踪等。改写中间件的作用,就是在不修改原始消息流的前提下,额外生成一份"面向业务用户"的进度摘要。
官方文档 README 在开篇特别强调:
⚠️ Temporary Solution — subject to change or removal at any time.
这是一个过渡性实现(stopgap)。团队正在考虑一种基于 hook 的方案,解耦和可扩展性会更好,欢迎提出改进设计的想法。
因此本文介绍的所有配置项与行为细节,适用前提为当前仓库版本;未来接口可能变化或被移除。
工作原理:透传优先,回合末追加改写
README 总结了四步核心流程,下文结合源码逐条印证:
- 原始消息原样透传——不做任何修改;
- 回合结束时(工具调用开始前 / 模型响应结束时),把累积的 thought + message 片段发给一次独立的 LLM 调用进行改写;
- 改写后的文本以新的
agent_message_chunk追加下发,并带_meta.rewritten: true; - 客户端自行决定展示哪个版本——展示层根据
_meta.rewritten选择原始或改写版。
回合(Turn)的边界在哪里
"回合"是改写的最小单元。从 TurnBuffer 的实现看,缓冲区分两类累积:
thoughts:来自agent_thought_chunk的文本片段(模型的思考内容);messages:来自agent_message_chunk的文本片段(模型回复内容)。
一个回合在两种情况下结束:模型开始发起tool_call(工具调用标志回合边界),或模型停止生成。flush()会拼接并重置缓冲,返回一个TurnContent结构(定义见 types.ts):
export interface TurnContent { thoughts: string[]; messages: string[]; hasToolCalls: boolean; }拦截、累积与异步改写
MessageRewriteMiddleware 是这套机制的编排核心,其interceptUpdate()方法处理每一条 ACP 更新:
- 遇到
tool_call更新时,先调用flushTurn()结算当前回合,再放行原始更新; - 遇到
agent_thought_chunk/agent_message_chunk时,先原样发送原始消息,再根据target配置决定是否累积; - 其他类型(
tool_call_update、plan、available_commands等)一律直接透传。
有两处值得注意的过滤逻辑:
- 来源排除:
agent_message_chunk若携带_meta.source为slash_command或vision_bridge_notice,则透传但不累积——这类消息是系统通知性质,不参与改写; - 空回合跳过:缓冲为空或改写输入文本短于 10 个字符时(LlmRewriter.ts 中
inputText.length < 10),直接跳过改写,避免为过渡性短回合浪费一次 LLM 调用。
改写本身是非阻塞的:flushTurn()把改写任务塞入pendingRewrites队列,在后台与工具执行并行运行。每次改写都会强制套用超时信号(AbortSignal.timeout(this.timeoutMs),并与调用方传入的AbortSignal合并),确保一次挂死的改写调用不会阻塞主流程。
在主会话的调用链中可以看到几个触发点(见 Session.ts):
- 模型响应结束、发出 usage 元数据时:
this.messageRewriter.flushTurn(pendingSend.signal)——注释明确写着"Kick off rewrite in background (non-blocking, runs parallel to tools)"; - 回合完整结束、prompt 即将返回前:
await this.messageRewriter.waitForPendingRewrites()——保证会话返回前所有在途改写都已落地; - 会话所有更新都经由
sendUpdate()出口(base-emitter.ts),当messageRewriter存在时先走interceptUpdate(update),否则直接透传。
waitForPendingRewrites()的实现采用"循环抽干"策略:每次把当前队列整体取出、置空新数组,等这批全部 settle 后再检查是否有新任务入队,直到队列为空——注释解释了这样做的原因:如果在 await 期间又有flushTurn入队,一次性快照再清空会静默丢掉这些迟到任务。
改写成功后,中间件发出形如这样的更新:
{ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: rewritten }, _meta: { ...turnMeta, // 原回合内捕获的 _meta(如 source 等) rewritten: true, // 客户端据此识别改写版 turnIndex: turnIdx, // 回合序号 }, }其中turnMeta来自回合内消息片段携带的_meta(captureTurnMeta()会累积合并)。源码中还有一个显式的扩展点REWRITE_META_EXCLUDED_KEYS——目前为空集合,注释说明它是"向改写消息的_meta中剔除某个键"的预留口子(早期版本曾在这里剔除backgroundTask/source/qwenDiscreteMessage,后来因下游离散消息路由依赖这些键而回退)。
改写失败(超时、LLM 报错等)不会中断会话:LlmRewriter.rewrite()内部捕获异常并返回null,中间件仅记录 debug 日志(Turn N: rewrite failed: ...)。
配置:settings.json 中的messageRewrite项
按 README 的说明,在settings.json中添加如下配置即可启用:
{ "messageRewrite": { "enabled": true, "target": "all", "promptFile": ".qwen/rewrite-prompt.txt", "model": "qwen3-plus", "contextTurns": 1, "timeoutMs": 60000 } }结合 types.ts 中的MessageRewriteConfig接口,各字段含义如下:
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
enabled | boolean | 必填 | 是否启用改写。Session.installRewriter()仅在rewriteConfig?.enabled为真时安装中间件 |
target | 'message' \| 'thought' \| 'all' | 必填 | 累积哪些消息类型:仅回复文本 / 仅思考内容 / 两者都改写 |
prompt | string | — | 内联改写提示词,作为改写 LLM 的系统提示词 |
promptFile | string | — | 改写提示词文件路径,相对 CWD 解析;同时配置时优先于prompt |
model | string | 空 | 改写使用的模型;留空则沿用当前会话模型 |
contextTurns | number \| 'all' | 1 | 携带几条历史改写结果作为上下文:0不带,N带最近 N 条,'all'带全部 |
timeoutMs | number | 30000 | 单次改写 LLM 调用超时(毫秒)。README 示例中设为60000 |
配置的加载与信任边界
配置读取逻辑在 config.ts 中:loadRewriteConfig()从用户级与工作区的originalSettings中读取messageRewrite键,且工作区配置仅在受信任(trusted)工作区中生效——其目的是防止不受信任的仓库通过自带工作区配置启用改写器、注入自定义提示词。优先级上,工作区配置覆盖用户级配置。
安装时机同样有讲究:Session.ts 中installRewriter()的注释明确写道"Must be called AFTER history replay to avoid rewriting historical messages"——即中间件必须装完历史消息回放之后才安装,避免把恢复会话时的历史内容也送去改写。
自定义提示词与内置回退
promptFile的解析在 LlmRewriter.ts 构造函数中完成,采用优雅降级策略:
- 文件不存在:记录
Rewrite prompt file not found: ..., using default警告,回退到内置默认提示词; - 文件存在但不可读(
EISDIR、EACCES等——existsSync对目录也返回 true,因此读操作本身仍可能失败):同样降级而非抛错,源码注释指出直接抛错会导致 ACP 会话启动崩溃(关联上游 issue #9752)。
README 也给出了对应的排障手段:设置QWEN_DEBUG_LOG_FILE环境变量可以捕获这类回退警告。内置默认提示词(DEFAULT_REWRITE_PROMPT)定义了改写的核心规则,可作为自定义提示词的参照:
- 严格基于原文——不虚构 Agent 未陈述的细节、计划或结论;
- 保留目标、决策、关键发现、结果、影响用户的错误、状态更新;
- 丢弃文件路径、工具/技能名、内部工具选择推理、代码片段、堆栈跟踪、"let me…" / "now I'll…" 之类填充语;
- 进度型回合(Agent 刚开始读文件、跑命令、探索代码)输出一句简短说明,避免用户面对"静默";
- 输入本身已是结构良好的面向用户内容(表格、列表、格式化结果)时只做轻度清理,保留结构;
- 纯内部操作(改自己的代码笔误、重试失败的工具调用、建临时目录)→ 返回空字符串;
- 数据原样保留——绝不改动数字、百分比、文件大小、错误码或引用输出。
此外,LlmRewriter会拼接上下文连续性指令:若提供了"Previous rewrite output"(上一轮改写结果),用户已经看过,不要重复、在其基础上推进;本轮若无新增内容则返回空字符串。
改写调用本身:参数与输入构造
改写通过核心包的runSideQuery()原语发起(见 LlmRewriter.ts),关键参数为:
purpose: 'acp-rewrite'——标识这次旁路查询的用途;model:rewriteConfig.model,未配置时回退this.config.getModel()(当前会话模型);maxAttempts: 1——注释解释了原因:这是尽力而为(best-effort)路径,失败只会静默跳过,用户完全无感知,因此不在瞬时故障上烧掉 7 次重试;systemInstruction: this.prompt——即配置或内置的改写提示词;config: { temperature: 0.3, maxOutputTokens: 1024 }——低温度保证输出稳定,上限 1024 token 限定摘要篇幅;abortSignal——中间件传入的带超时合并信号。
输入文本的构造分三段,用中文标记分段拼接:[内部推理](thoughts 用换行连接)、[回复文本](messages),以及最前面的[上一轮改写结果](由outputHistory提供,多条之间以\n---\n分隔)。其中outputHistory按contextTurns裁剪保留——'all'时全量保存,有限值时只保留最近 N 条,0时干脆不存——避免长会话中历史无界增长。
输出侧还有两道过滤:返回空或长度不足 5 字符的结果直接跳过(返回null,不发出改写消息);异常统一吞掉并返回null。
下游消费:客户端如何对待_meta.rewritten
改写版消息通过_meta.rewritten: true与原始消息区分,"客户端决定展示哪个版本"并非只是纸面约定,渠道层(channels)已有实际的去重逻辑。例如 DaemonChannelBridge.ts 与 AcpBridge.ts 在处理后台通知响应的离散消息(qwenDiscreteMessage: true)时,判断条件包含meta['rewritten'] !== true——即改写版的后台响应会被忽略,避免同一条回复通过"原始版 + 改写版"两条消息重复投递,对应测试用例名为ignores a rewritten background response to avoid duplicate delivery(见 AcpBridge.test.ts、DaemonChannelBridge.test.ts)。
测试与验证
该模块自带四个单元测试文件,覆盖各组件行为边界:
- LlmRewriter.test.ts——提示词加载回退、上下文裁剪、短输入/空输出跳过等;
- MessageRewriteMiddleware.test.ts——拦截逻辑、回合边界、超时信号、
_meta.rewritten标记; - TurnBuffer.test.ts——累积与
flush()重置; - config.test.ts——配置加载与工作区信任判断。
此外 Session.test.ts 中存在针对flushTurn与waitForPendingRewrites时序关系的集成断言,确保改写结算发生在模型循环内部、且终端更新先于待决改写等待完成——防止回合标记错误地"粘"到下一回合的改写摘要上。
局限与演进方向
从源码注释和 README 的声明可以确认,这套中间件当前的设计取舍包括:
- 临时定位:官方明确其为 stopgap 方案,正在评估基于 hook 的替代设计以获得更好的解耦与可扩展性;配置项与行为随时可能变化或移除;
- 同步读文件:
promptFile在中间件构造时同步读取(readFileSync),失败即降级为默认提示词,运行期不做热重载; - 无持久化:改写结果只作为一次性 chunk 下发,不进入会话历史;
outputHistory也是内存态,仅用于同会话内的上下文连续性。
对于集成方而言,当前可靠的做法是:在受信任工作区中通过用户级或工作区settings.json的messageRewrite键启用功能、用promptFile定制业务化改写规则、通过QWEN_DEBUG_LOG_FILE观察改写输入输出与降级警告,并在客户端依据_meta.rewritten与turnIndex消费改写版消息。
【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考