中文 English

OpenClaw TUI 变复读机?thinking 和回复重复的临时止血手册

发布时间: 2026-06-02
OpenClaw AI Agent OpenAI Compatible Streaming TUI Debug 补丁 故障排查

先说结论

如果你在 OpenClaw TUI 里看到同一段 thinking 重复出现、同一段回复正文连续出现两遍,先不要急着清空模型、删除 session 或者怀疑所有 provider 配置都坏了。这个问题很可能不是“历史消息太多”,也不一定是模型真的想复读,而是 OpenAI-compatible 流式响应里同时出现了增量 delta 和额外的完整 message,OpenClaw 某些版本在上层聚合时把两份内容都算进去了。

临时解决思路很简单:备份本地安装文件,在 OpenClaw 的运行聚合层增加一个非常保守的去重保护;它只处理“同一轮、同一模型、完全重复的 text/thinking 块”,然后重启 gateway,用 marker 测试确认 TUI 和 session 落盘都只剩一份内容。

本文是一份可复用的临时修复记录。所有路径、模型名、网关地址和 Token 都做了脱敏处理,真实环境请用自己的安装路径和模型名替换。不要把本文里的占位符当成真实配置直接复制。

OpenClaw TUI duplicate reply patch

图 1:本文关注的是 OpenClaw TUI/agent 输出重复,不是简单的终端显示闪烁。

1. 问题背景:为什么这个问题很容易被误判

OpenClaw 这类 Agent 工具通常会把很多层东西串在一起:本地 TUI、gateway、agent runner、模型 provider、OpenAI-compatible 网关、上游模型、session 日志、工具调用事件、thinking/reasoning 字段、回复正文以及最后的投递渠道。任何一层出问题,表面上都可能表现成“回复不对”。

这次遇到的现象更迷惑:TUI 里不是乱码,也不是空回复,而是很整齐地重复。比如你让它只回复一次 PING_ONCE,结果界面里出现 PING_ONCEPING_ONCE;你让它跑一次简单检查,thinking 段落会出现两遍,正文段落也会出现两遍;如果中间发生工具调用,工具调用参数还有可能出现 {{...}}{{...}} 这种重复拼接。

这类问题很容易被误判成下面几种原因:

  1. session 太旧:历史上下文里已经有类似内容,模型照着重复了一遍。
  2. 模型配置重复:同一个 provider/model 在多个配置文件里出现,OpenClaw 同时调用了两次。
  3. TUI 渲染 bug:底层只写了一份,终端界面画了两份。
  4. 定时任务干扰:cron job 或后台 channel 正在复用同一个 agent。
  5. 模型本身复读:模型生成时真的输出了两遍。

这些都可能发生,但不要先入为主。正确做法是分层验证: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 又把它当成一次可追加事件,就会得到两份文本。

正常流式协议只累计 delta

图 2:正常流式客户端只累计增量 delta,最终得到一份完整文本。

额外完整 message 尾块可能导致重复累计

图 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> 这些占位符就足够了。

参考资料