10.5 回复塑形与抑制

生成模型:Claude Opus 4.6 (anthropic/claude-opus-4-6) Token 消耗:输入 ~520k tokens,输出 ~45k tokens(本章合计)


模型产生的原始文本并不能直接发送给用户。在"流式传输 → 分块 → 发送"这条管线的最后阶段,OpenClaw 还需要完成一系列**回复塑形(Reply Shaping)**操作:过滤静默标记、去除重复消息、将工具执行摘要内联到对话中。这些操作共同保证了用户收到的消息既干净又完整。


10.5.1 NO_REPLY 静默令牌过滤

问题背景

在某些场景下,AI 代理"听到"了消息但不需要回复——例如群聊中他人之间的对话、心跳检测、或者用户设置了自动回复条件未满足的情况。此时模型仍然会被调用(以维持上下文连贯),但它产出的文本应该被完全丢弃,不发送给用户。

OpenClaw 用一个特殊的**静默令牌(Silent Reply Token)**来实现这一机制。

令牌定义

静默令牌定义在 src/auto-reply/tokens.ts 中,极其简洁:

// src/auto-reply/tokens.ts
export const HEARTBEAT_TOKEN = "HEARTBEAT_OK";
export const SILENT_REPLY_TOKEN = "NO_REPLY";

系统提示词会指导模型:当不需要回复时,只输出 NO_REPLY 即可。与之类似的 HEARTBEAT_OK 用于心跳场景——定时检查 AI 是否仍在正常运行。

检测逻辑

isSilentReplyText 函数负责判断一段文本是否包含静默令牌:

这个检测函数的设计有两个要点:

  1. 双位置检测:不仅检查文本开头,还检查结尾。这是因为某些模型可能在 NO_REPLY 之前或之后添加空格、标点或换行。

  2. 单词边界感知:使用 \b(word boundary)和 \W(非单词字符)确保不会误匹配——例如 NO_REPLY_NEEDED 不会被当作静默令牌。

衍生解释:正则表达式的单词边界 \b

在正则表达式中,\b 匹配"单词字符"与"非单词字符"之间的位置(不消耗字符)。单词字符包括字母、数字和下划线 [a-zA-Z0-9_]。例如在字符串 "NO_REPLY done" 中,\bNO_REPLY\b 可以匹配 NO_REPLY,因为它的左边是字符串开头(等效于边界),右边是空格(非单词字符)。但 \bNO_REPLY\b 不会匹配 "NO_REPLY_NEEDED" 中的 NO_REPLY,因为 Y 右边的 _ 是单词字符,不构成边界。

在回复指令解析中的应用

静默令牌的检测被集成到 parseReplyDirectives 函数中,这是回复处理的核心入口:

parseReplyDirectives 的返回值中,isSilent 标志和被清空的 text 共同作用——上层代码在看到空文本和无媒体 URL 时,会自然跳过该消息的发送。

整个静默令牌的处理流程可以概括为:


10.5.2 消息工具去重

问题背景

OpenClaw 的 AI 代理可以通过消息工具(Messaging Tool)主动向用户或其他渠道发送消息——例如使用 message 工具向 Discord 频道发送一条通知。问题在于:模型在使用 message(action="send", to="user", content="Hello!") 工具后,往往还会在其最终文本回复中重复同样的内容("Hello!")。如果不做去重,用户会收到两条相同的消息:一条来自工具调用,一条来自最终回复。

消息工具识别

首先需要知道哪些工具属于"消息工具"。这由 isMessagingTool 函数判定:

核心消息工具有两个:

  • sessions_send:会话内发送消息

  • message:通用消息发送(支持指定 provider、channel、to)

此外,任何注册了 actions 的通道插件(如 Discord、Slack)也被视为消息工具——它们的工具名本身就是通道 ID(如 discordslack)。

发送动作判定

不是所有消息工具调用都是"发送"——message 工具还支持 readlist 等动作。只有发送动作才需要去重:

追踪与提交机制

消息工具去重使用了一个**"先暂存、后提交"**的两阶段设计。这一设计的核心考量是:如果工具调用失败了,那么消息实际上并没有发出去,此时就不应该抑制最终回复中的相同文本。

handleToolExecutionStart 中,文本被暂存到 pendingMessagingTexts

handleToolExecutionEnd 中,根据执行结果决定是提交还是丢弃:

文本归一化与模糊匹配

去重比较不是简单的字符串相等——模型可能在回复中稍微修改措辞(加 emoji、改大小写等)。OpenClaw 使用归一化比较

归一化步骤:

步骤
输入示例
输出示例

原始文本

" Hello 👋 WORLD 🌍 "

trim()

"Hello 👋 WORLD 🌍"

toLowerCase()

"hello 👋 world 🌍"

去 emoji

"hello world "

合并空白

"hello world"

匹配算法使用双向子串包含而不是严格相等:如果回复文本包含了已发送的工具消息文本(或反过来),就视为重复。这捕获了模型在回复中"引用"工具消息内容的常见模式。

但注意 10 字符最小长度门槛:太短的文本(如 "ok"、"done")不会参与去重,因为它们过于通用,容易产生误抑制。

在块输出中的检查点

去重检查发生在两个关键位置:

  1. emitBlockChunk——块流输出时(10.2 节中介绍的 BlockChunker 输出回调):

  1. handleMessageEnd——消息结束时的最终回复发送:

这两个检查点确保了无论是流式分块还是完整消息结束,重复内容都不会泄漏给用户。


10.5.3 工具摘要内联

问题背景

当 AI 代理执行工具(如读取文件、运行命令、搜索网页)时,用户在 Telegram、Discord 等通道中等待的过程可能很长。为了提供更好的体验,OpenClaw 会在工具执行时向用户发送工具摘要(Tool Summary)——一行简短的状态信息,告诉用户"代理正在做什么"。

摘要的触发与格式

工具摘要在 handleToolExecutionStart 中触发:

emitToolSummary 通过 formatToolAggregate 将工具名称和元信息格式化为一行可读文本:

formatToolAggregate 的格式化逻辑

formatToolAggregate(位于 src/auto-reply/tool-meta.ts)是工具摘要的格式化核心。它将工具名映射到 emoji + 标签,并将元信息(通常是文件路径)进行智能分组:

产出的摘要示例:

工具调用
格式化结果

read(path="src/index.ts")

📖 Read: src/index.ts

exec(cmd="npm test", pty=true)

⚡ Exec: pty · npm test

read 多个同目录文件

📖 Read: src/{index.ts, config.ts}

message(action="send")

💬 Message: send

花括号折叠(brace collapse)是 shell 中常见的路径缩写语法——当同一目录下有多个文件时,用 {file1, file2} 替代逐个列出,使摘要更紧凑。

路径缩短

元信息中的文件路径会经过 shortenMetashortenHomePath 处理,将用户主目录替换为 ~

这意味着 /Users/john/projects/app/src/index.ts 会显示为 ~/projects/app/src/index.ts——既保留了足够的路径信息,又不会暴露系统用户名或浪费消息空间。

工具显示配置

resolveToolDisplay(位于 src/agents/tool-display.ts)从一个 JSON 配置文件 tool-display.json 中查找每个工具的显示信息:

这个设计将工具的功能标识显示表现完全解耦——添加新工具时只需要在 JSON 配置中新增一条记录,无需修改代码逻辑。

详细输出模式

除了工具摘要,OpenClaw 还支持工具输出内联——将工具执行的完整输出嵌入到对话中。这由 verboseLevel 配置控制:

详细程度
工具摘要
工具输出

off

on

full

full 模式下,工具输出通过 emitToolOutput 发送:

这意味着在 full 模式下,用户会看到类似这样的消息:

Last updated