Qwen Code ACP 消息改写中间件:用 LLM 把终端编码 Agent 的输出改写成业务友好语言
2026/9/14 2:01:43 网站建设 项目流程

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 总结了四步核心流程,下文结合源码逐条印证:

  1. 原始消息原样透传——不做任何修改;
  2. 回合结束时(工具调用开始前 / 模型响应结束时),把累积的 thought + message 片段发给一次独立的 LLM 调用进行改写;
  3. 改写后的文本以新的agent_message_chunk追加下发,并带_meta.rewritten: true
  4. 客户端自行决定展示哪个版本——展示层根据_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_updateplanavailable_commands等)一律直接透传。

有两处值得注意的过滤逻辑:

  • 来源排除agent_message_chunk若携带_meta.sourceslash_commandvision_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来自回合内消息片段携带的_metacaptureTurnMeta()会累积合并)。源码中还有一个显式的扩展点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接口,各字段含义如下:

字段类型默认值说明
enabledboolean必填是否启用改写。Session.installRewriter()仅在rewriteConfig?.enabled为真时安装中间件
target'message' \| 'thought' \| 'all'必填累积哪些消息类型:仅回复文本 / 仅思考内容 / 两者都改写
promptstring内联改写提示词,作为改写 LLM 的系统提示词
promptFilestring改写提示词文件路径,相对 CWD 解析;同时配置时优先于prompt
modelstring改写使用的模型;留空则沿用当前会话模型
contextTurnsnumber \| 'all'1携带几条历史改写结果作为上下文:0不带,N带最近 N 条,'all'带全部
timeoutMsnumber30000单次改写 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警告,回退到内置默认提示词;
  • 文件存在但不可读(EISDIREACCES等——existsSync对目录也返回 true,因此读操作本身仍可能失败):同样降级而非抛错,源码注释指出直接抛错会导致 ACP 会话启动崩溃(关联上游 issue #9752)。

README 也给出了对应的排障手段:设置QWEN_DEBUG_LOG_FILE环境变量可以捕获这类回退警告。内置默认提示词(DEFAULT_REWRITE_PROMPT)定义了改写的核心规则,可作为自定义提示词的参照:

  1. 严格基于原文——不虚构 Agent 未陈述的细节、计划或结论;
  2. 保留目标、决策、关键发现、结果、影响用户的错误、状态更新;
  3. 丢弃文件路径、工具/技能名、内部工具选择推理、代码片段、堆栈跟踪、"let me…" / "now I'll…" 之类填充语;
  4. 进度型回合(Agent 刚开始读文件、跑命令、探索代码)输出一句简短说明,避免用户面对"静默";
  5. 输入本身已是结构良好的面向用户内容(表格、列表、格式化结果)时只做轻度清理,保留结构;
  6. 纯内部操作(改自己的代码笔误、重试失败的工具调用、建临时目录)→ 返回空字符串;
  7. 数据原样保留——绝不改动数字、百分比、文件大小、错误码或引用输出。

此外,LlmRewriter会拼接上下文连续性指令:若提供了"Previous rewrite output"(上一轮改写结果),用户已经看过,不要重复、在其基础上推进;本轮若无新增内容则返回空字符串。

改写调用本身:参数与输入构造

改写通过核心包的runSideQuery()原语发起(见 LlmRewriter.ts),关键参数为:

  • purpose: 'acp-rewrite'——标识这次旁路查询的用途;
  • modelrewriteConfig.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分隔)。其中outputHistorycontextTurns裁剪保留——'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 中存在针对flushTurnwaitForPendingRewrites时序关系的集成断言,确保改写结算发生在模型循环内部、且终端更新先于待决改写等待完成——防止回合标记错误地"粘"到下一回合的改写摘要上。

局限与演进方向

从源码注释和 README 的声明可以确认,这套中间件当前的设计取舍包括:

  • 临时定位:官方明确其为 stopgap 方案,正在评估基于 hook 的替代设计以获得更好的解耦与可扩展性;配置项与行为随时可能变化或移除;
  • 同步读文件promptFile在中间件构造时同步读取(readFileSync),失败即降级为默认提示词,运行期不做热重载;
  • 无持久化:改写结果只作为一次性 chunk 下发,不进入会话历史;outputHistory也是内存态,仅用于同会话内的上下文连续性。

对于集成方而言,当前可靠的做法是:在受信任工作区中通过用户级或工作区settings.jsonmessageRewrite键启用功能、用promptFile定制业务化改写规则、通过QWEN_DEBUG_LOG_FILE观察改写输入输出与降级警告,并在客户端依据_meta.rewrittenturnIndex消费改写版消息。

【免费下载链接】qwen-codeAn open-source AI coding agent that lives in your terminal.项目地址: https://gitcode.com/GitHub_Trending/qw/qwen-code

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询