OpenClaw TUI 变复读机?thinking 和回复重复的临时止血手册
先说结论
如果你在 OpenClaw TUI 里看到同一段 thinking 重复出现、同一段回复正文连续出现两遍,先不要急着清空模型、删除 session 或者怀疑所有 provider 配置都坏了。这个问题很可能不是“历史消息太多”,也不一定是模型真的想复读,而是 OpenAI-compatible 流式响应里同时出现了增量
delta和额外的完整message,OpenClaw 某些版本在上层聚合时把两份内容都算进去了。临时解决思路很简单:备份本地安装文件,在 OpenClaw 的运行聚合层增加一个非常保守的去重保护;它只处理“同一轮、同一模型、完全重复的 text/thinking 块”,然后重启 gateway,用 marker 测试确认 TUI 和 session 落盘都只剩一份内容。
本文是一份可复用的临时修复记录。所有路径、模型名、网关地址和 Token 都做了脱敏处理,真实环境请用自己的安装路径和模型名替换。不要把本文里的占位符当成真实配置直接复制。
图 1:本文关注的是 OpenClaw TUI/agent 输出重复,不是简单的终端显示闪烁。
1. 问题背景:为什么这个问题很容易被误判
OpenClaw 这类 Agent 工具通常会把很多层东西串在一起:本地 TUI、gateway、agent runner、模型 provider、OpenAI-compatible 网关、上游模型、session 日志、工具调用事件、thinking/reasoning 字段、回复正文以及最后的投递渠道。任何一层出问题,表面上都可能表现成“回复不对”。
这次遇到的现象更迷惑:TUI 里不是乱码,也不是空回复,而是很整齐地重复。比如你让它只回复一次 PING_ONCE,结果界面里出现 PING_ONCEPING_ONCE;你让它跑一次简单检查,thinking 段落会出现两遍,正文段落也会出现两遍;如果中间发生工具调用,工具调用参数还有可能出现 {{...}}{{...}} 这种重复拼接。
这类问题很容易被误判成下面几种原因:
- session 太旧:历史上下文里已经有类似内容,模型照着重复了一遍。
- 模型配置重复:同一个 provider/model 在多个配置文件里出现,OpenClaw 同时调用了两次。
- TUI 渲染 bug:底层只写了一份,终端界面画了两份。
- 定时任务干扰:cron job 或后台 channel 正在复用同一个 agent。
- 模型本身复读:模型生成时真的输出了两遍。
这些都可能发生,但不要先入为主。正确做法是分层验证:TUI、session 文件、OpenAI-compatible 网关直连、OpenClaw raw stream、provider 基础库。只有把重复发生在哪一层确认清楚,补丁才不会越打越乱。
2. 问题表现:判断你是不是同类故障
这类故障通常有几个共同特征。第一,重复内容会写入 OpenClaw 的 session 文件。也就是说,它不只是 TUI 渲染时多显示一遍。你可以找到最近的 session .jsonl 文件,搜索刚刚测试用的 marker。如果 assistant message 的 content 里已经是重复文本,说明问题发生在渲染之前。
第二,重复常常是“整段重复”,不是随机多字。比如正文块是:
OC_TEST_ONCEOC_TEST_ONCE
thinking 块也可能是完整段落重复:
The user asks ... I should answer exactly once.
The user asks ... I should answer exactly once.
第三,直接用非流式请求打到同一个 OpenAI-compatible 网关时,结果可能是正常的;而流式路径才会重复。这一点很关键,因为它把问题范围从“模型总是复读”缩小到“流式解析/聚合路径有兼容性问题”。第四,OpenClaw 里模型列表可能看起来完全正常。你用 openclaw models list 看到只有一个主模型、一个备用模型,并不代表流式事件不会重复。模型配置清爽和流式协议兼容是两件事。
3. 先收集证据:不要直接改配置
推荐用一个唯一 marker 做测试,例如:
MARKER="OC_DEDUPE_TEST_$(date +%H%M%S)"
openclaw agent \
--agent main \
--session-key "agent:main:dedupe-test-${MARKER}" \
--message "Reply exactly once with: ${MARKER}" \
--timeout 120 \
--json
如果输出变成:
OC_DEDUPE_TEST_101820OC_DEDUPE_TEST_101820
继续检查 session 落盘:
SESSION_FILE="<从 JSON 输出里拿到的 sessionFile>"
grep -n "OC_DEDUPE_TEST" "$SESSION_FILE"
如果 assistant message 里已经重复,说明不是 TUI 单纯渲染问题。再用 OpenAI-compatible 接口做流式和非流式对比。示意命令如下,注意把 <BASE_URL>、<API_KEY>、<MODEL_ID> 换成自己的值:
curl -sS "$BASE_URL/chat/completions" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "<MODEL_ID>",
"messages": [{"role":"user","content":"Reply exactly once with: PING_ONCE"}],
"stream": false
}' | jq '.choices[0].message.content'
然后测试流式:
curl -N "$BASE_URL/chat/completions" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "<MODEL_ID>",
"messages": [{"role":"user","content":"Reply exactly once with: STREAM_ONCE"}],
"stream": true
}'
正常的 OpenAI Chat Completions 流式客户端应该累计 choices[].delta,最后看到 finish_reason 后结束。若你的兼容网关在最后又吐出一个带完整 message.content 的尾块,而某层 SDK/adapter 又把它当成一次可追加事件,就会得到两份文本。
图 2:正常流式客户端只累计增量 delta,最终得到一份完整文本。
图 3:如果增量之后又出现完整 message,朴素累计器就可能把两份都拼进去。
4. 根因:不是两个模型同时回答,而是流事件被重复聚合
这次的根因可以概括成一句话:OpenAI-compatible 网关返回了 OpenClaw 当前路径不完全兼容的流式事件形状,OpenClaw 上层又缺少足够保守的重复折叠。
直接非流式请求正常,说明模型不是必然复读;直接调用基础 provider 流式库也可能正常,说明底层库本身未必总是错;但是 OpenClaw agent runner 带完整系统提示、工具上下文、session 写入和消息事件聚合时,会看到重复的 text_delta 和重复的 thinking 事件。raw stream 里能观察到这样的模式:
thinking_delta: "The user is ..."
text_delta: "OC_TEST_ONCE"
thinking_delta: "The user is ..." # 完整重复
text_delta: "OC_TEST_ONCE" # 完整重复
message_end: rawText="OC_TEST_ONCEOC_TEST_ONCE"
这说明重复已经进入 OpenClaw 的 assistant message 聚合路径。单纯清理 session、删旧 provider、重启 TUI 都不能从根上解决。它们最多让你少看到旧记录,但下一轮流式事件仍然会重复。
5. 临时解决方案:先备份,再打本地补丁
下面这份脚本是“临时止血”思路,不是上游永久修复。它适合 macOS/Homebrew 或全局 npm 风格安装的 OpenClaw。其他安装方式请先找到自己的 OpenClaw 安装目录。
5.1 找到安装文件并备份
OPENCLAW_ROOT="$(npm root -g 2>/dev/null)/openclaw"
if [ ! -d "$OPENCLAW_ROOT" ]; then
OPENCLAW_ROOT="/opt/homebrew/lib/node_modules/openclaw"
fi
SELECTION_FILE="$(find "$OPENCLAW_ROOT/dist" -maxdepth 1 -name 'selection-*.js' | head -1)"
PROVIDER_FILE="$OPENCLAW_ROOT/node_modules/@earendil-works/pi-ai/dist/providers/openai-completions.js"
BACKUP_DIR="$HOME/.openclaw/backups/dedupe-patch-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$BACKUP_DIR"
cp "$SELECTION_FILE" "$BACKUP_DIR/$(basename "$SELECTION_FILE")"
cp "$PROVIDER_FILE" "$BACKUP_DIR/openai-completions.js"
shasum -a 256 "$BACKUP_DIR"/* > "$BACKUP_DIR/SHA256SUMS"
echo "Backup: $BACKUP_DIR"
不要跳过备份。因为这是直接修改本地安装目录,升级 OpenClaw 或重新安装时也可能覆盖它。
5.2 给 OpenClaw 聚合层加重复折叠
下面脚本会做三件事:text_delta 如果等于当前已累计文本,就跳过;assistant message 结束前,如果 text/thinking 是精确的“前半段 + 后半段”重复,就折半;如果 content 数组里有完全相同的 text/thinking block,只保留一份。
python3 - <<'PATCH_PY'
from pathlib import Path
import os
root = Path(os.environ.get('OPENCLAW_ROOT', '/opt/homebrew/lib/node_modules/openclaw'))
selection_files = list((root / 'dist').glob('selection-*.js'))
if not selection_files:
raise SystemExit('selection-*.js not found')
path = selection_files[0]
s = path.read_text()
old = 'if (evtType === "text_delta") return delta;'
new = '''if (evtType === "text_delta") {
\t\t\tif (delta && accumulatedText && delta === accumulatedText) return "";
\t\t\treturn delta;
\t\t}'''
if old in s and new not in s:
s = s.replace(old, new, 1)
anchor = 'function resolveSilentReplyFallbackText(params) {'
helper = '''function collapseExactDoubledString(value) {
\t\tif (typeof value !== "string" || value.length < 8 || value.length % 2 !== 0) return value;
\t\tconst half = value.length / 2;
\t\tconst first = value.slice(0, half);
\t\treturn first === value.slice(half) ? first : value;
\t}
\tfunction normalizeDedupeText(value) {
\t\treturn typeof value === "string" ? value.trim().replace(/\\s+/g, " ") : "";
\t}
\tfunction dedupeOpenAiCompatAssistantMessage(message) {
\t\tif (!message || message.role !== "assistant" || !Array.isArray(message.content)) return;
\t\tconst seenThinking = new Set();
\t\tconst seenText = new Set();
\t\tconst next = [];
\t\tfor (const block of message.content) {
\t\t\tif (!block || typeof block !== "object") { next.push(block); continue; }
\t\t\tif (block.type === "text" && typeof block.text === "string") {
\t\t\t\tblock.text = collapseExactDoubledString(block.text);
\t\t\t\tconst key = normalizeDedupeText(block.text);
\t\t\t\tif (key && seenText.has(key)) continue;
\t\t\t\tif (key) seenText.add(key);
\t\t\t} else if (block.type === "thinking" && typeof block.thinking === "string") {
\t\t\t\tblock.thinking = collapseExactDoubledString(block.thinking);
\t\t\t\tconst key = `${block.thinkingSignature ?? ""}:${normalizeDedupeText(block.thinking)}`;
\t\t\t\tif (key !== ":" && seenThinking.has(key)) continue;
\t\t\t\tif (key !== ":") seenThinking.add(key);
\t\t\t} else if (block.type === "toolCall" && typeof block.partialArgs === "string") {
\t\t\t\tblock.partialArgs = collapseExactDoubledString(block.partialArgs);
\t\t\t}
\t\t\tnext.push(block);
\t\t}
\t\tmessage.content = next;
\t}
\t'''
if helper.strip() not in s:
if anchor not in s:
raise SystemExit('helper anchor not found')
s = s.replace(anchor, helper + anchor, 1)
old_update = 'if (msg?.role !== "assistant" || isTranscriptOnlyOpenClawAssistantMessage$1(msg)) return;\n\tctx.noteLastAssistant(msg);'
new_update = 'if (msg?.role !== "assistant" || isTranscriptOnlyOpenClawAssistantMessage$1(msg)) return;\n\tdedupeOpenAiCompatAssistantMessage(msg);\n\tctx.noteLastAssistant(msg);'
if old_update in s and new_update not in s:
s = s.replace(old_update, new_update, 1)
old_end = 'const assistantMessage = msg;\n\tconst assistantPhase = resolveAssistantMessagePhase(assistantMessage);'
new_end = 'const assistantMessage = msg;\n\tdedupeOpenAiCompatAssistantMessage(assistantMessage);\n\tconst assistantPhase = resolveAssistantMessagePhase(assistantMessage);'
if old_end in s and new_end not in s:
s = s.replace(old_end, new_end, 1)
path.write_text(s)
print(path)
PATCH_PY
这段补丁故意写得保守:它不尝试理解语义,只处理完全重复。也就是说,如果模型真的写了两段不同内容,它不会合并;如果模型输出的是完全重复的前后两半,它才折叠。
5.3 可选:给 provider 流式尾块加保护
有些兼容网关会在 finish_reason 后继续发尾块。可以给 pi-ai 的 OpenAI completions provider 加一条防线:已经看到 finish_reason 后,不再继续处理后续 chunk。
python3 - <<'PATCH_PY'
from pathlib import Path
import os
root = Path(os.environ.get('OPENCLAW_ROOT', '/opt/homebrew/lib/node_modules/openclaw'))
path = root / 'node_modules/@earendil-works/pi-ai/dist/providers/openai-completions.js'
s = path.read_text()
old = '''const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined;
if (!choice)
continue;'''
new = '''const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined;
if (!choice)
continue;
if (hasFinishReason)
continue;'''
if old in s and new not in s:
s = s.replace(old, new, 1)
path.write_text(s)
print(path)
PATCH_PY
这个补丁不是所有环境都必须打,但作为临时防线通常无害。真正关键的是上一节的 OpenClaw 聚合层去重。
5.4 一份完整可执行的自动补丁脚本
如果你不想分段复制上面的命令,可以直接使用下面这份完整脚本。它会自动寻找 OpenClaw 安装目录、备份目标文件、检查补丁锚点、写入补丁,并支持 --dry-run 预检查和 --restart 自动重启 gateway。
推荐执行顺序是:先 dry-run,再真正打补丁,最后重启并验证。
mkdir -p ~/.openclaw/patches
nano ~/.openclaw/patches/patch_openclaw_duplicate_stream.py
# 粘贴下面完整脚本后保存
python3 ~/.openclaw/patches/patch_openclaw_duplicate_stream.py --dry-run
python3 ~/.openclaw/patches/patch_openclaw_duplicate_stream.py --restart
如果你的 OpenClaw 不在默认位置,可以显式指定安装目录:
python3 ~/.openclaw/patches/patch_openclaw_duplicate_stream.py --root /path/to/openclaw --restart
完整脚本如下:
#!/usr/bin/env python3
# Patch local OpenClaw runtime files to suppress exact duplicate thinking/text
# content from OpenAI-compatible streaming responses.
#
# Usage:
# python3 patch_openclaw_duplicate_stream.py
# python3 patch_openclaw_duplicate_stream.py --dry-run
# python3 patch_openclaw_duplicate_stream.py --root /opt/homebrew/lib/node_modules/openclaw
# python3 patch_openclaw_duplicate_stream.py --restart
from __future__ import annotations
import argparse
import datetime as _dt
import hashlib
import os
from pathlib import Path
import shutil
import subprocess
import sys
from typing import Iterable, List, Optional, Tuple
SELECTION_TEXT_DELTA_OLD = 'if (evtType === "text_delta") return delta;'
SELECTION_TEXT_DELTA_NEW = '''if (evtType === "text_delta") {
\t\t\tif (delta && accumulatedText && delta === accumulatedText) return "";
\t\t\treturn delta;
\t\t}'''
HELPER_ANCHOR = 'function resolveSilentReplyFallbackText(params) {'
HELPER_CODE = '''function collapseExactDoubledString(value) {
\t\tif (typeof value !== "string" || value.length < 8 || value.length % 2 !== 0) return value;
\t\tconst half = value.length / 2;
\t\tconst first = value.slice(0, half);
\t\treturn first === value.slice(half) ? first : value;
\t}
\tfunction normalizeDedupeText(value) {
\t\treturn typeof value === "string" ? value.trim().replace(/\\s+/g, " ") : "";
\t}
\tfunction dedupeOpenAiCompatAssistantMessage(message) {
\t\tif (!message || message.role !== "assistant" || !Array.isArray(message.content)) return;
\t\tconst seenThinking = new Set();
\t\tconst seenText = new Set();
\t\tconst next = [];
\t\tfor (const block of message.content) {
\t\t\tif (!block || typeof block !== "object") { next.push(block); continue; }
\t\t\tif (block.type === "text" && typeof block.text === "string") {
\t\t\t\tblock.text = collapseExactDoubledString(block.text);
\t\t\t\tconst key = normalizeDedupeText(block.text);
\t\t\t\tif (key && seenText.has(key)) continue;
\t\t\t\tif (key) seenText.add(key);
\t\t\t} else if (block.type === "thinking" && typeof block.thinking === "string") {
\t\t\t\tblock.thinking = collapseExactDoubledString(block.thinking);
\t\t\t\tconst key = `${block.thinkingSignature ?? ""}:${normalizeDedupeText(block.thinking)}`;
\t\t\t\tif (key !== ":" && seenThinking.has(key)) continue;
\t\t\t\tif (key !== ":") seenThinking.add(key);
\t\t\t} else if (block.type === "toolCall" && typeof block.partialArgs === "string") {
\t\t\t\tblock.partialArgs = collapseExactDoubledString(block.partialArgs);
\t\t\t}
\t\t\tnext.push(block);
\t\t}
\t\tmessage.content = next;
\t}
\t'''
UPDATE_LAST_OLD = 'if (msg?.role !== "assistant" || isTranscriptOnlyOpenClawAssistantMessage$1(msg)) return;\n\tctx.noteLastAssistant(msg);'
UPDATE_LAST_NEW = 'if (msg?.role !== "assistant" || isTranscriptOnlyOpenClawAssistantMessage$1(msg)) return;\n\tdedupeOpenAiCompatAssistantMessage(msg);\n\tctx.noteLastAssistant(msg);'
ASSISTANT_END_OLD = 'const assistantMessage = msg;\n\tconst assistantPhase = resolveAssistantMessagePhase(assistantMessage);'
ASSISTANT_END_NEW = 'const assistantMessage = msg;\n\tdedupeOpenAiCompatAssistantMessage(assistantMessage);\n\tconst assistantPhase = resolveAssistantMessagePhase(assistantMessage);'
PROVIDER_OLD = '''const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined;
if (!choice)
continue;'''
PROVIDER_NEW = '''const choice = Array.isArray(chunk.choices) ? chunk.choices[0] : undefined;
if (!choice)
continue;
if (hasFinishReason)
continue;'''
class PatchError(RuntimeError):
pass
def log(message: str) -> None:
print(f"[openclaw-patch] {message}")
def run_text(cmd: List[str]) -> Optional[str]:
try:
return subprocess.check_output(cmd, text=True, stderr=subprocess.DEVNULL).strip()
except Exception:
return None
def candidate_roots(cli_root: Optional[str]) -> Iterable[Path]:
if cli_root:
yield Path(cli_root).expanduser()
return
env_root = os.environ.get("OPENCLAW_ROOT")
if env_root:
yield Path(env_root).expanduser()
npm_root = run_text(["npm", "root", "-g"])
if npm_root:
yield Path(npm_root) / "openclaw"
yield Path("/opt/homebrew/lib/node_modules/openclaw")
yield Path("/usr/local/lib/node_modules/openclaw")
def find_root(cli_root: Optional[str]) -> Path:
for root in candidate_roots(cli_root):
if (root / "dist").is_dir():
return root
raise PatchError("OpenClaw root not found. Pass --root /path/to/openclaw or set OPENCLAW_ROOT.")
def find_selection_file(root: Path) -> Path:
files = sorted((root / "dist").glob("selection-*.js"), key=lambda p: p.stat().st_mtime, reverse=True)
if not files:
raise PatchError(f"selection-*.js not found under {root / 'dist'}")
return files[0]
def sha256(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def backup_files(paths: List[Path], dry_run: bool) -> Optional[Path]:
backup_root = Path.home() / ".openclaw" / "backups"
stamp = _dt.datetime.now().strftime("dedupe-patch-%Y%m%d-%H%M%S")
backup_dir = backup_root / stamp
if dry_run:
log(f"dry run: would create backup at {backup_dir}")
return None
backup_dir.mkdir(parents=True, exist_ok=False)
manifest = []
for path in paths:
if not path.exists():
continue
target = backup_dir / path.name
shutil.copy2(path, target)
manifest.append(f"{sha256(target)} {target.name}\n")
(backup_dir / "SHA256SUMS").write_text("".join(manifest), encoding="utf-8")
log(f"backup created: {backup_dir}")
return backup_dir
def replace_or_confirm(text: str, old: str, new: str, label: str, required: bool = True) -> Tuple[str, bool]:
if new in text:
log(f"already patched: {label}")
return text, False
if old not in text:
message = f"pattern not found: {label}"
if required:
raise PatchError(message)
log(f"warning: {message}")
return text, False
log(f"patching: {label}")
return text.replace(old, new, 1), True
def patch_selection(path: Path) -> bool:
text = path.read_text(encoding="utf-8")
changed_any = False
text, changed = replace_or_confirm(text, SELECTION_TEXT_DELTA_OLD, SELECTION_TEXT_DELTA_NEW, "text_delta duplicate guard")
changed_any = changed_any or changed
if HELPER_CODE.strip() in text:
log("already patched: dedupe helper")
else:
if HELPER_ANCHOR not in text:
raise PatchError("helper anchor not found in selection file")
log("patching: dedupe helper")
text = text.replace(HELPER_ANCHOR, HELPER_CODE + HELPER_ANCHOR, 1)
changed_any = True
text, changed = replace_or_confirm(text, UPDATE_LAST_OLD, UPDATE_LAST_NEW, "dedupe before noteLastAssistant")
changed_any = changed_any or changed
text, changed = replace_or_confirm(text, ASSISTANT_END_OLD, ASSISTANT_END_NEW, "dedupe before assistant phase resolution")
changed_any = changed_any or changed
path.write_text(text, encoding="utf-8")
return changed_any
def patch_provider(path: Path) -> bool:
if not path.exists():
log(f"warning: provider file not found: {path}")
return False
text = path.read_text(encoding="utf-8")
text2, changed = replace_or_confirm(text, PROVIDER_OLD, PROVIDER_NEW, "provider finish_reason guard", required=False)
if changed:
path.write_text(text2, encoding="utf-8")
return changed
def restart_gateway() -> None:
uid = os.getuid()
label = f"gui/{uid}/ai.openclaw.gateway"
try:
subprocess.check_call(["launchctl", "kickstart", "-k", label])
log(f"restarted LaunchAgent: {label}")
except Exception as exc:
log(f"restart failed, restart OpenClaw manually: {exc}")
def main() -> int:
parser = argparse.ArgumentParser(description="Patch OpenClaw duplicate streaming output locally.")
parser.add_argument("--root", help="OpenClaw package root, for example /opt/homebrew/lib/node_modules/openclaw")
parser.add_argument("--dry-run", action="store_true", help="Check paths and patch anchors without writing files")
parser.add_argument("--restart", action="store_true", help="Restart ai.openclaw.gateway with launchctl after patching")
args = parser.parse_args()
try:
root = find_root(args.root)
selection = find_selection_file(root)
provider = root / "node_modules/@earendil-works/pi-ai/dist/providers/openai-completions.js"
log(f"OpenClaw root: {root}")
log(f"selection file: {selection}")
log(f"provider file: {provider}")
if args.dry_run:
patch_selection_text = selection.read_text(encoding="utf-8")
for label, old, new in [
("text_delta duplicate guard", SELECTION_TEXT_DELTA_OLD, SELECTION_TEXT_DELTA_NEW),
("dedupe before noteLastAssistant", UPDATE_LAST_OLD, UPDATE_LAST_NEW),
("dedupe before assistant phase resolution", ASSISTANT_END_OLD, ASSISTANT_END_NEW),
]:
if new in patch_selection_text:
log(f"dry run: already patched: {label}")
elif old in patch_selection_text:
log(f"dry run: patchable: {label}")
else:
raise PatchError(f"dry run failed, pattern not found: {label}")
if HELPER_CODE.strip() not in patch_selection_text and HELPER_ANCHOR not in patch_selection_text:
raise PatchError("dry run failed, helper anchor not found")
log("dry run passed")
return 0
backup_files([selection, provider], dry_run=False)
changed_selection = patch_selection(selection)
changed_provider = patch_provider(provider)
if changed_selection or changed_provider:
log("patch complete")
else:
log("no changes needed; files already looked patched")
if args.restart:
restart_gateway()
else:
log("restart OpenClaw gateway/TUI before verifying")
return 0
except PatchError as exc:
print(f"[openclaw-patch] ERROR: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())
脚本退出码也可以用于自动化:0 表示检查或补丁成功,2 表示没有找到安装目录、关键文件或预期代码锚点。遇到 2 时不要硬改,先确认 OpenClaw 版本是否已经变化。
图 4:备份、折叠重复、验证,是这类本地补丁的最小闭环。
6. 重启 gateway 并验证
补丁写入后,重启 OpenClaw gateway。macOS LaunchAgent 环境常见命令如下:
launchctl kickstart -k gui/$(id -u)/ai.openclaw.gateway
如果你用的是其他启动方式,例如 systemd、手动 node 命令或容器,请用对应方式重启。重启后不要只看进程存在,要看 gateway 是否 ready,再跑一次 marker 测试:
MARKER="OC_FIX_OK_$(date +%H%M%S)"
openclaw agent \
--agent main \
--session-key "agent:main:dedupe-test-${MARKER}" \
--message "Reply exactly once with: ${MARKER}" \
--timeout 120 \
--json
期望输出应该只有一份:
OC_FIX_OK_101820
再检查 session 文件。assistant 内容应该类似这样:
[
{"type":"thinking","thinking":"..."},
{"type":"text","text":"OC_FIX_OK_101820"}
]
如果 session 文件里正文仍然是 OC_FIX_OK_101820OC_FIX_OK_101820,说明补丁没有命中实际运行文件,或者你的 OpenClaw 版本函数名/文件名已经变化。此时不要继续叠补丁,先确认 gateway 进程实际从哪个安装目录启动。
ps eww -p <GATEWAY_PID> | tr ' ' '\n' | grep OPENCLAW
ps -p <GATEWAY_PID> -o command=
7. 回滚方法
如果补丁后出现异常,直接从备份恢复:
cp "$BACKUP_DIR/selection-*.js" "$SELECTION_FILE"
cp "$BACKUP_DIR/openai-completions.js" "$PROVIDER_FILE"
launchctl kickstart -k gui/$(id -u)/ai.openclaw.gateway
恢复后重新跑 marker 测试。别忘了,如果你升级或重装 OpenClaw,本地安装目录里的补丁可能会消失。这并不奇怪,说明你需要重新评估新版本是否已经修复,或者重新应用临时补丁。
8. Q&A
Q1:清空 session 有用吗?
只能排除旧上下文干扰,不能修复流式事件重复。如果新 session 里仍然重复,说明问题不在历史记录。
Q2:为什么不直接关掉 streaming?
可以先尝试,但不一定生效。某些 OpenClaw runner 路径对 streaming: false 的支持取决于版本和配置层级。即使配置文件里写了,也要用新 session 验证落盘内容。不要把“配置已写入”等同于“运行时已生效”。
Q3:这个补丁会不会误删模型真实输出?
它只折叠完全重复的文本块和 thinking 块,并且只跳过与已累计文本完全一致的重复 delta。正常不同内容不会被合并。风险仍然存在,所以建议只作为临时补丁,并保留备份。
Q4:为什么工具调用参数也可能重复?
因为工具调用参数在流式场景里也可能逐片累计。如果兼容网关最后又发一份完整参数,朴素累计器就可能得到两份 JSON。本文补丁里对 partialArgs 做了精确折半保护,但更好的长期方案仍然是上游适配器正确识别尾块。
Q5:这是 OpenClaw 的 bug,还是兼容网关的 bug?
更准确地说,是兼容性边界问题。OpenAI 官方示例强调流式客户端处理的是 delta;如果某个兼容网关额外发了完整 message 尾块,客户端就需要识别并忽略它。OpenClaw 也应该在上层对明显重复做保护,避免 UI 和 session 被污染。
9. 总结
这类重复问题最难的地方,不是写补丁,而是不要误判。TUI 显示重复只是表象;session 落盘重复说明问题已经进入消息聚合;非流式正常而流式重复说明模型本身未必有问题;raw stream 里出现重复 text_delta 才是关键证据。
临时止血的原则是:先备份,再打最小补丁,再重启,再用 marker 验证输出和 session。不要把真实网关地址、Token、内网主机名和 session ID 写进博客或 issue;公开分享时,用 <BASE_URL>、<API_KEY>、<MODEL_ID> 这些占位符就足够了。
参考资料
- OpenAI API Reference: Chat Completions / Streaming: https://platform.openai.com/docs/api-reference/chat-streaming
- OpenAI Cookbook: How to stream completions: https://cookbook.openai.com/examples/how_to_stream_completions
- OpenClaw CLI Agent documentation: https://docs.openclaw.ai/cli/agent
- OpenClaw CLI Models documentation: https://docs.openclaw.ai/cli/models