原创

AppChat 真流式 + 思考过程推送配置指南

适用组件:openclaw 核心网关 + openclaw-plugin-appchat 渠道插件
整理日期:2026-10-15(基于 openclaw32 生产服务器实际排障过程)


1. 背景:两类典型故障现象

现象 A:伪流式(整文后打字机回放)

日志特征:

[appchat] typewriter replay: messageId=e2e-32-... chars=324 blocks=33 chunk=10 interval=60ms
[appchat] final cap pushed: messageId=e2e-32-... seq=34 (core final deliver missing)

判读:模型生成完毕后整文一次性到达插件(324 字装进一个 block 帧),插件把整文切成 33 片按 60ms 间隔"回放"——客户端看起来在流式,实际帧全部挤在生成期结束后的 2 秒窗口内,是伪流式。final cap pushed 是插件补发的收口帧(核心 final deliver 缺失路径),无害,但它出现说明核心没走完整的块流式收尾。

真流式的日志特征应该是:生成期内出现多条 [appchat] reply pushed: ... stream(seq=N,kind=block,done=false) 小帧,且没有 typewriter replay。

现象 B:正文真流式正常,但零思考帧

日志里搜不到:

[appchat] reasoning delta pushed

且 embedded run start 日志中 thinking=off。原因见 §4 三级配置。


2. 版本要求(先对齐版本再调配置)

组件 要求 说明
openclaw 核心网关 ≥ 2026.9.9 核心恢复真增量块交付;2026.8.1-beta.3 的"块流式长回复 agent run 不终结" bug 已修复
appchat 插件 ≥ v1.7(20261009 包) 真流式透传:block 帧直接透传核心增量,typewriter 改为显式 opt-in
appchat 插件 ≥ v1.9(20261014 包) 思考过程流推送(stream.kind:"thinking" 帧)、typewriter final 兜底放宽至 [text, card...] 结构
appchat 插件 ≥ v1.10(20261015 包) 思考回合正文真流式修复:正文流式从插件自建 onPartialReply 通道切换到核心块通道。旧通道在思考回合被核心整回合扣留正文增量(正文只剩 final 整文一帧)。要"思考帧 + 正文流式"同时工作,必须用 v1.10
webchat 测试页 ≥ 20261013 思考帧渲染为"💭 思考过程"折叠面板
App 客户端 已适配 kind:"thinking" 帧 未适配的旧 App 会把思考文本当普通正文渲染——生产账号未适配前不要开思考推送

插件包与 dist/src/outbound.js 的 MD5 对应关系见 appchat/docs/upgrade-product.md(部署手册,含备份/回滚步骤)。


3. 真流式配置(现象 A 的修复)

真流式由核心块粒度和插件侧开关共同决定,缺一不可。

3.1 核心块粒度:agents.defaults.blockStreamingChunk

核心默认块粒度是 800~1200 字符/块(src/auto-reply/reply/block-streaming.ts 的 DEFAULT_BLOCK_STREAM_MIN=800 / DEFAULT_BLOCK_STREAM_MAX=1200)。默认值下,几百字的短回复不足一个块,整文只会以单帧 block 到达——即使插件全透传,客户端看到的也是"啪一下整体出现"。

要逐字级真流式,必须显式调小:

"agents": {
  "defaults": {
    "blockStreamingChunk": {
      "minChars": 1,
      "maxChars": 2,
      "breakPreference": "sentence"
    }
  }
}

实测参数:qwen3.7-plus 长文生成期每 0.30.5s 交付 510 字符增量,1.9 万字符长文 1988 块在 10.5s 生成期内渐进送达。

3.2 插件侧开关:账号内 streaming.block.enabled

"channels": {
  "appchat": {
    "accounts": {
      "<appId>": {
        "streaming": {
          "block": {
            "enabled": true,
            "coalesce": { "minChars": 1, "maxChars": 10, "idleMs": 0 }
          }
        }
      }
    }
  }
}

关键陷阱:多账号形态下,显式命中的账号配置不继承顶层字段(accounts.ts 的 resolveAccountConfig 命中即 return,不合并)。账号里漏写 streaming.block.enabled: true 会静默退化单块整文,无任何报错。账号要用的每一项必须写进账号内。

3.3 不要挂 typewriter(除非明确要兜底)

// 删除——它会盖掉真流式,把每个整文/长 block 切片回放:
"typewriter": { "chunkChars": 10, "intervalMs": 60 }

typewriter 的合法用途只有两个:核心回到整文交付时的兼容兜底,或需要比模型真实节奏更慢的展示节奏。配了它,日志出现 typewriter replay: ... interval=60ms 就是它在工作(插件代码默认 interval 是 40ms,日志里出现 60 一定是显式配置的)。

3.4 排错对照

日志特征 结论
typewriter replay: ... chunk=10 interval=40ms 账号配了 typewriter 且未写 intervalMs(默认 40)
typewriter replay: ... interval=60ms 账号显式配了 typewriter(60 非代码默认值)——删掉它
单条 reply pushed ... kind=block 大帧 + 无 replay blockStreamingChunk 未调小,整文一帧;核心粒度问题
生成期多条 reply pushed ... kind=block 小帧 ✅ 真流式正常

4. 思考过程推送配置(现象 B 的修复)

思考文本是否推送给 App 由三级配置共同决定,缺一不可(任何一级缺失都是零思考帧):

层级 配置位置 缺失后果
① 插件开关 账号内 streaming.reasoning.enabled: true(默认关闭;多账号不继承顶层) 无 thinking 帧,正文行为不变
② 网关思考级别 agents.defaults.thinkingDefault: "high" 模型不进思考回合,run 恒 thinking=off
③ 模型条目 models.providers.<provider>.models[] 条目 reasoning: true + qwen 系另需 compat: { "thinkingFormat": "qwen" } 请求不带 enable_thinking、不解析 reasoning_content(reasoning: false 会把 thinking 钳为仅 off,只配 ①② 不够)
{
  "agents": { "defaults": { "thinkingDefault": "high" } },
  "models": {
    "providers": {
      "qwen3.7-plus": {
        "models": [
          { "id": "qwen3.7-plus", "reasoning": true, "compat": { "thinkingFormat": "qwen" } }
        ]
      }
    }
  },
  "channels": {
    "appchat": {
      "accounts": {
        "test": {
          "streaming": {
            "block": { "enabled": true, "coalesce": { "minChars": 1, "maxChars": 10, "idleMs": 0 } },
            "reasoning": { "enabled": true }
          }
        }
      }
    }
  }
}

4.1 覆盖陷阱:agents.entries 覆盖 agents.defaults

今天实际踩到的坑:agents.defaults.thinkingDefault: "high" 已配,但 appchat 绑定的 agent 是 main,而 agents.entries.main.thinkingDefault: "off" 覆盖了 defaults——每次 run 实际都是 thinking=off,三级条件 ② 形同虚设。

"agents": {
  "defaults": { "thinkingDefault": "high" },   // ← 不够!
  "entries": {
    "main": { "thinkingDefault": "off" }        // ← 实际生效的是这里
  }
}

两种解法:

  1. 直接改 entries:把 agents.entries.main.thinkingDefault 改为 "high"。注意 main 是所有渠道共用的 agent——企微等其它渠道的回复也会开始思考(首响应延迟变长、token 消耗增加,受 thinking_budget 上限约束)。
  2. 给 appchat 单独建 agent:agents.entries 里复制一份(如 appchat-agent,thinkingDefault: "high"),bindings 数组加 {"agentId": "appchat-agent", "match": {"channel": "appchat"}}。渠道间互不影响。

4.2 生效与验证

  • 三级配置均在 openclaw.json 内,热重载生效(日志 config hot reload applied,无需重启容器)。
  • 生效行为:生成期思考文本以 stream.kind:"thinking" 增量帧推送(实测约 40 帧/60 秒),思考重写时推 replace:true 全量修正帧;思考文本独立渲染,不混入正文拼接。
  • 注意 v1.9 及以前插件在思考回合正文会退化为 final 整文(核心门控),思考 + 正文双流式必须用 v1.10(见 §2 版本表)。

验证命令:

# ①②③ 全生效:应见 thinking=high;见 thinking=off = ② 被 entries 覆盖或 ③ 缺失
docker-compose logs --tail 50 openclaw-gateway | grep -a "embedded run start"

# ① 生效:思考帧推送日志应出现
docker-compose logs --tail 100 openclaw-gateway | grep -a "reasoning delta pushed"

# 无思考帧排错:
#   thinking=off        → ② entries 覆盖 或 ③ 模型条目缺 reasoning/compat
#   thinking=high 但无 reasoning delta → ① 未写进账号内(不继承顶层)

5. 调用链路与代码速查(排障时知道往哪看)

5.1 端到端调用链路图

sequenceDiagram
    autonumber
    participant App as App / webchat 客户端
    participant GW as 网关 Gateway<br/>(openclaw 核心)
    participant IN as 插件 inbound.ts
    participant CORE as 核心回复管线<br/>(dispatch + block-streaming)
    participant LLM as 模型 API<br/>(qwen3.7-plus / silom)
    participant OUT as 插件 outbound.ts

    App->>GW: POST /plugins/appchat/webhook<br/>(HMAC 签名 + 消息体)
    GW->>IN: webhook 入站(验签/限流后)
    IN->>IN: 解析账号 streaming 配置<br/>block.enabled / typewriter / reasoning.enabled
    IN->>CORE: dispatchReplyWithBufferedBlockDispatcher<br/>disableBlockStreaming=false(块通道开启)<br/>+ onReasoningStream(reasoning ①开启时)
    CORE->>CORE: 读 thinkingDefault(agents.entries 覆盖 defaults)<br/>+ 模型条目 reasoning/compat.thinkingFormat
    CORE->>LLM: model-fetch POST /chat/completions<br/>(thinking=high 时带 enable_thinking)
    LLM-->>CORE: SSE 增量流(reasoning_content + content)

    rect rgb(230, 245, 255)
        note over CORE,App: 思考流(三级配置 ①②③ 全生效才有)
        CORE->>OUT: onReasoningStream 思考快照
        OUT->>App: 回调推送 stream.kind="thinking" 帧<br/>(攒批, 日志 reasoning delta pushed)
    end

    rect rgb(230, 255, 230)
        note over CORE,App: 正文真流式(块通道, 不受思考门控影响)
        loop 生成期 (每 0.3~0.5s 一个增量)
            CORE->>OUT: deliver(kind="block", 增量块)<br/>(粒度由 blockStreamingChunk 控制)
            OUT->>App: 回调推送 stream.kind="block" done=false<br/>(日志 reply pushed ... kind=block)
        end
    end

    alt 块流式成功(常规路径)
        CORE->>OUT: final deliver(抑制文本载荷)
        OUT->>App: 空收尾帧 items=[] done=true
    else 核心未派发 final deliver
        OUT->>App: finalCap 补发空 done:true<br/>(日志 final cap pushed)
    else typewriter 开启时整文 block 到达
        OUT->>App: 整文切片按 intervalMs 回放<br/>(日志 typewriter replay, 伪流式)
    end
    App->>App: 聚合:block 按序拼接<br/>thinking 独立渲染 / replace 清空重建

排障时按图索骥:帧不流式查左边(核心粒度/块通道),无思考帧查右上(三级配置),收口异常查右下(final/finalCap)。

5.2 插件入站(appchat/openclaw-plugin-appchat/src/inbound.ts)

  • resolveChannelStreamingBlockEnabled(account.config):读账号内 streaming.block.enabled,决定 disableBlockStreaming。
  • typewriter 显式 opt-in:仅 blockStreamingEnabled && account.config.streaming?.block?.typewriter 同时成立才启用;chunkChars clamp 到 [1,200] 默认 10,intervalMs clamp 到 [5,500] 默认 40(日志出现 60 = 显式配置)。
  • reasoningEnabled = account.config.streaming?.reasoning?.enabled === true:成立时 replyOptions 挂 onReasoningStream: deliver.deliverReasoning + streamReasoningInNonStreamModes: true(让 reasoningMode 非 stream 的 webchat 用户也能收到思考流)。

5.3 插件出站(appchat/openclaw-plugin-appchat/src/outbound.ts)

  • kind:"block":typewriter 未开启时直接透传核心增量块(真流式主路径)。
  • typewriter 回放:replayItem.text.length > chunkChars 时切片推送(typewriter replay 日志);final 兜底:正文零 block 帧(!partialDelivered)且 final 为 [text, card...] 结构时对文本部分回放(typewriter final fallback 日志)。
  • finalCap():dispatch resolve 后核心未派发 final deliver 时补发空 done:true 收口(final cap pushed 日志)。
  • deliverReasoning():接收核心思考快照,攒批推 kind:"thinking" 帧(reasoning delta pushed 日志)。

5.4 核心(src/auto-reply/reply/block-streaming.ts)

  • 块粒度默认 800~1200 字符;agents.defaults.blockStreamingChunk 可调小至逐字级。
  • 块通道不受思考回合草稿门控影响(shouldEmitPartialReplies 只扣留 onPartialReply 通道)——这是 v1.10 换通道后"思考 + 正文双流式"的依据。

5.5 交付帧契约(App 端聚合规则)

帧 语义
stream.kind:"block"(done:false) 正文增量,按序追加拼接
stream.kind:"thinking"(done:false) 思考增量,独立渲染;带 replace:true 时清空重建思考面板
stream.replace:true 清空已累积对应流文本后以本帧重建
final(done:true) 收口;块流式成功时常为空收尾帧 items:[];携带 text 时带 replace:true(终结全文为唯一权威)

6. 端到端验证方法

6.1 帧时间分布分析(判真伪流式的客观标准)

appchat/test-truestream-partial.mjs:发长回复请求 → 轮询回调落盘日志 → 分析帧时间分布。判据:

  • 帧分布在生成期(首帧→终帧 span > 3s)
  • 帧间隔为数十 ms(median ≥ 20ms)
  • 无 <1.5s 窗口内的密集帧爆发(伪流式特征:全部帧挤在整文到达后)

三判据全过 = 真流式 ✅;否则为整文后回放(伪流式)❌。

6.2 服务器侧快速核查

# typewriter replay 计数(应为 0)
docker-compose logs --since 48h openclaw-gateway | grep -ac 'typewriter replay'

# 最近的块推送 / 思考推送
docker-compose logs --since 48h openclaw-gateway | \
  grep -aE 'typewriter replay|reply pushed.*kind=block|reasoning delta pushed' | tail -6

# 配置现场核对
python3 - <<'PY'
import json
c = json.load(open('/openclaw-data/.openclaw32/openclaw.json'))
ad = c.get('agents', {})
print('defaults.blockStreamingChunk =', json.dumps(ad.get('defaults', {}).get('blockStreamingChunk')))
print('defaults.thinkingDefault =', ad.get('defaults', {}).get('thinkingDefault'))
for name, ent in ad.get('entries', {}).items():
    print('entries[%s].thinkingDefault =' % name, ent.get('thinkingDefault'))
for pk, pv in c.get('models', {}).get('providers', {}).items():
    for m in pv.get('models', []):
        print(pk, m.get('id'), '| reasoning:', m.get('reasoning'), '| compat:', json.dumps(m.get('compat')))
for name, acct in c.get('channels', {}).get('appchat', {}).get('accounts', {}).items():
    print('account', name, '| streaming =', json.dumps(acct.get('streaming'), ensure_ascii=False))
PY

预期:blockStreamingChunk 为细粒度;生效 agent 的 thinkingDefault 非 off;模型条目 reasoning: true + qwen 系带 compat.thinkingFormat;账号 streaming 内无 typewriter、有 reasoning.enabled。


7. 生产账号上线检查单

按序执行,每步有明确预期:

  1. 核心 ≥ 2026.9.9,插件 ≥ v1.10(20261015 包),MD5 与部署手册比对
  2. 停网关,备份 openclaw.json 与插件目录(见 upgrade-product.md 第 1 步)
  3. agents.defaults.blockStreamingChunk 调细(minChars 1 / maxChars 2)
  4. 确认生效 agent(查 bindings → agents.entries)的 thinkingDefault 非 off;注意 defaults 被 entries 覆盖的陷阱(§4.1)
  5. 模型条目补 reasoning: true + compat: { "thinkingFormat": "qwen" }(qwen 系)
  6. 目标账号内写全 streaming.block.enabled + streaming.reasoning.enabled(不继承顶层);删 typewriter
  7. 起网关,e2e 发长消息验证:embedded run start 见 thinking=high;日志出现 reasoning delta pushed + 多条 kind=block 小帧;typewriter replay 计数为 0
  8. 用 test-truestream-partial.mjs 的三判据确认真流式
  9. App 适配确认:客户端能正确渲染 kind:"thinking" 帧(思考面板)与 replace:true 修正帧,再对生产账号开启 ①

8. 已知行为边界

  • 开思考后每条回复都进思考回合(寒暄也会思考),首响应延迟变长;thinking_budget 控制上限。
  • 块流式关闭(block.enabled:false)时回退单帧整文 stream:{seq:1,done:true,kind:"final"},App 无需感知。
  • 核心部分终结路径不派发 final deliver 时插件补空收口帧(final cap pushed),App 端总能等到 done:true。
  • 旧 App 忽略 stream 字段会把多块流式当多条独立回复渲染——流式开启本身也要求 App 适配(v1.2 起的契约)。
正文到此结束
Loading...