适用组件:openclaw 核心网关 + openclaw-plugin-appchat 渠道插件
整理日期:2026-10-15(基于 openclaw32 生产服务器实际排障过程)
日志特征:
[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。
日志里搜不到:
[appchat] reasoning delta pushed
且 embedded run start 日志中 thinking=off。原因见 §4 三级配置。
| 组件 | 要求 | 说明 |
|---|---|---|
| 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(部署手册,含备份/回滚步骤)。
真流式由核心块粒度和插件侧开关共同决定,缺一不可。
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 生成期内渐进送达。
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 会静默退化单块整文,无任何报错。账号要用的每一项必须写进账号内。
// 删除——它会盖掉真流式,把每个整文/长 block 切片回放:
"typewriter": { "chunkChars": 10, "intervalMs": 60 }
typewriter 的合法用途只有两个:核心回到整文交付时的兼容兜底,或需要比模型真实节奏更慢的展示节奏。配了它,日志出现 typewriter replay: ... interval=60ms 就是它在工作(插件代码默认 interval 是 40ms,日志里出现 60 一定是显式配置的)。
| 日志特征 | 结论 |
|---|---|
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 小帧 |
✅ 真流式正常 |
思考文本是否推送给 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 }
}
}
}
}
}
}
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" } // ← 实际生效的是这里
}
}
两种解法:
agents.entries.main.thinkingDefault 改为 "high"。注意 main 是所有渠道共用的 agent——企微等其它渠道的回复也会开始思考(首响应延迟变长、token 消耗增加,受 thinking_budget 上限约束)。agents.entries 里复制一份(如 appchat-agent,thinkingDefault: "high"),bindings 数组加 {"agentId": "appchat-agent", "match": {"channel": "appchat"}}。渠道间互不影响。config hot reload applied,无需重启容器)。stream.kind:"thinking" 增量帧推送(实测约 40 帧/60 秒),思考重写时推 replace:true 全量修正帧;思考文本独立渲染,不混入正文拼接。验证命令:
# ①②③ 全生效:应见 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 → ① 未写进账号内(不继承顶层)
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)。
appchat/openclaw-plugin-appchat/src/inbound.ts)resolveChannelStreamingBlockEnabled(account.config):读账号内 streaming.block.enabled,决定 disableBlockStreaming。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 用户也能收到思考流)。appchat/openclaw-plugin-appchat/src/outbound.ts)kind:"block":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 日志)。src/auto-reply/reply/block-streaming.ts)agents.defaults.blockStreamingChunk 可调小至逐字级。shouldEmitPartialReplies 只扣留 onPartialReply 通道)——这是 v1.10 换通道后"思考 + 正文双流式"的依据。| 帧 | 语义 |
|---|---|
stream.kind:"block"(done:false) |
正文增量,按序追加拼接 |
stream.kind:"thinking"(done:false) |
思考增量,独立渲染;带 replace:true 时清空重建思考面板 |
stream.replace:true |
清空已累积对应流文本后以本帧重建 |
final(done:true) |
收口;块流式成功时常为空收尾帧 items:[];携带 text 时带 replace:true(终结全文为唯一权威) |
appchat/test-truestream-partial.mjs:发长回复请求 → 轮询回调落盘日志 → 分析帧时间分布。判据:
三判据全过 = 真流式 ✅;否则为整文后回放(伪流式)❌。
# 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。
按序执行,每步有明确预期:
openclaw.json 与插件目录(见 upgrade-product.md 第 1 步)agents.defaults.blockStreamingChunk 调细(minChars 1 / maxChars 2)thinkingDefault 非 off;注意 defaults 被 entries 覆盖的陷阱(§4.1)reasoning: true + compat: { "thinkingFormat": "qwen" }(qwen 系)streaming.block.enabled + streaming.reasoning.enabled(不继承顶层);删 typewriterembedded run start 见 thinking=high;日志出现 reasoning delta pushed + 多条 kind=block 小帧;typewriter replay 计数为 0kind:"thinking" 帧(思考面板)与 replace:true 修正帧,再对生产账号开启 ①thinking_budget 控制上限。block.enabled:false)时回退单帧整文 stream:{seq:1,done:true,kind:"final"},App 无需感知。final cap pushed),App 端总能等到 done:true。stream 字段会把多块流式当多条独立回复渲染——流式开启本身也要求 App 适配(v1.2 起的契约)。