1. 为什么流式输出值得单独折腾一天
如果你用过 ChatGPT 或者 Claude 的网页版,一定注意过它的回复是一个字一个字“打”出来的。这不是前端做的动画特效,而是模型本身就在一个 token 一个 token 地往外吐。问题在于,从模型吐出第一个 token,到你在自己的 OpenClaw 客户端里看到第一个字,中间隔着一条完整的链路:模型 → API → Agent 运行时 → Gateway → 客户端。这条链路上任何一个环节的缓冲策略没配好,你看到的要么是“打字机卡顿”,要么是等十几秒才蹦出一整段,要么干脆收到一条被截断的消息。
OpenClaw 的流式输出链路,核心要解决三件事:第一,把不同模型厂商(OpenAI 的delta.content、Anthropic 的content_block_delta)的 SSE 格式统一成内部可处理的 Block 结构;第二,在 Block 层面做状态机管理,让text、tool_use、tool_result这些不同类型的块能正确组装;第三,把组装好的内容按语义边界切成适合消息平台发送的 Chunk,并通过防抖控制更新频率。这篇就沿着这条链路,给你一份可以直接复制到本地的config.toml骨架,配上统一的 Key/API 通道,然后一步步验证逐块接收和断流重连。
适合谁看:已经在本地跑过 OpenClaw 基础对话、想搞清楚流式链路每一环怎么配的人;或者你正在自己写 Agent 框架,想参考一套可落地的 SSE 处理方案。下面所有配置和命令我都实际跑过,你照着改改就能复现。
2. 前置准备:统一 Key 与 API 通道
在动config.toml之前,先把模型访问通道理顺。OpenClaw 支持直接填各家厂商的 Key,但如果你同时用多个模型,逐个配 Key 会很乱。我习惯用一个统一的 API 通道来收口,这样切换模型时只改model字段,不用动 Key。
TaoToken 的 API 地址是https://taotoken.net/api,兼容 OpenAI 的/v1/chat/completions和 Anthropic 的/v1/messages两种流式接口。你需要在控制台创建一个 API Key,然后把它填进 OpenClaw 的配置里。创建 Key 的入口在控制台的 API Keys 页面,建议给这个 Key 起个能区分用途的名字,比如openclaw-local-dev,方便后面排查是哪个环境在调用。
拿到 Key 之后,先别急着写进配置文件,用 curl 验证一下通道是否通。这一步很关键,因为后面 OpenClaw 报的很多错,根源其实在 Key 或通道上,提前排掉能省很多时间。
curl https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer sk-你的Key" \ -H "Content-Type: application/json" \ -d '{ "model": "claude-sonnet-4-20250514", "stream": true, "messages": [{"role": "user", "content": "用一句话介绍流式输出"}] }'如果返回的是一串以data:开头的 SSE 行,最后以data: [DONE]结束,说明通道和 Key 都没问题。注意stream必须显式设为true,否则你拿到的是完整 JSON,不是流。这一步通了,再往下配 OpenClaw。
3. 可复制的 config.toml 骨架
OpenClaw 的流式行为几乎全部由config.toml控制。下面这份骨架是我在本地调通之后整理的最小可用版本,你可以直接复制,把api_key换成自己的。
[gateway] host = "127.0.0.1" port = 8080 # 流式响应超时,单位秒。设太短会在长回复时被切断 stream_timeout = 120 [provider] # 统一走 TaoToken 通道,切换模型只改 model 字段 base_url = "https://taotoken.net/api" api_key = "sk-你的Key" model = "claude-sonnet-4-20250514" # 开启流式,关闭则退化为一次性返回 stream = true [streaming] # 是否启用 Block 状态机管理 enable_block = true # 首字超时,超过这个时间没收到第一个 token 就重试 first_token_timeout = 15 # 断流后最大重连次数 max_reconnect = 3 # 重连退避基数,单位毫秒,实际间隔为 base * 2^n reconnect_backoff_ms = 500 [chunk] # 单条消息最大字符数,留出平台限制的安全余量 max_length = 3800 # 最小切割长度,避免碎片化 min_length = 200 # 切割优先级:code_block > paragraph > sentence > char boundary_priority = ["code_block", "paragraph", "sentence", "char"] [telegram] # 若接入 Telegram,开启消息编辑实现流式更新 enable_edit = true # 防抖间隔,单位毫秒,控制 editMessage 频率 edit_debounce_ms = 100几个参数值得单独说。first_token_timeout设成 15 秒是因为有些模型在冷启动或长上下文时首 token 会慢,设太短会误判为失败。reconnect_backoff_ms用指数退避,第一次重连等 500ms,第二次 1s,第三次 2s,避免网络抖动时疯狂重试把通道打满。chunk.max_length设 3800 而不是 4096,是给 Telegram 的 4096 限制留了余量,因为 Markdown 转义后字符数会膨胀。
配置写完后,用 OpenClaw 自带的配置校验命令过一遍,能提前发现字段拼写错误:
openclaw config validate --file ./config.toml如果输出config is valid,就可以启动 Gateway 了。
4. 逐块接收与断流重连的验证
配置只是纸面功夫,真正要确认的是流式链路在运行时是否按预期工作。我分两步验证:先看逐块接收,再模拟断流看重连。
4.1 逐块接收验证
启动 Gateway 后,用一个带stream的请求打进去,观察返回的 SSE 行。OpenClaw 在开启enable_block后,会把原始 delta 组装成 Block 事件,你可以在日志里看到类似这样的输出:
openclaw gateway start --config ./config.toml --log-level debug然后在另一个终端发请求:
curl -N http://127.0.0.1:8080/v1/chat \ -H "Content-Type: application/json" \ -d '{"message": "写一段 200 字的流式输出说明", "stream": true}'-N参数关闭 curl 的缓冲,这样你能实时看到每一行。正常的话,你会看到text类型的 Block 从streaming状态逐步累积,最后变成complete。如果所有内容一次性涌出来,说明中间某层缓冲没关掉,重点检查provider.stream是否为true,以及反向代理(如果你加了)是否开了proxy_buffering off。
4.2 断流重连验证
断流重连是流式链路里最容易出问题的地方。我的验证方法是:在流进行到一半时,手动把网络断开(比如临时改掉base_url指向一个不可达地址,或者用防火墙规则阻断),观察 OpenClaw 是否按max_reconnect和reconnect_backoff_ms的配置重试。
一个更可控的做法是写个小脚本,模拟服务端在发送若干 delta 后突然关闭连接:
# mock_stream_server.py from http.server import BaseHTTPRequestHandler, HTTPServer import time class Handler(BaseHTTPRequestHandler): def do_POST(self): self.send_response(200) self.send_header("Content-Type", "text/event-stream") self.end_headers() for i in range(5): chunk = f'data: {{"type":"content_block_delta","delta":{{"text":"块{i} "}}}}\n\n' self.wfile.write(chunk.encode()) self.wfile.flush() time.sleep(0.3) # 模拟断流:直接关闭,不发 [DONE] self.wfile.close() HTTPServer(("127.0.0.1", 9090), Handler).serve_forever()把config.toml里的base_url临时指向http://127.0.0.1:9090,发请求后你会看到 OpenClaw 在收到 5 个块后检测到连接关闭,然后按退避策略重连。重连时如果服务端不支持Last-Event-ID续传,OpenClaw 会把已收到的内容作为完整结果发出,并在末尾标注“回复可能不完整”。这个降级行为是符合预期的,宁可给用户一个不完整但可读的结果,也不要让他一直等。
验证通过后,把base_url改回https://taotoken.net/api,重连逻辑在真实通道上同样生效。
5. 本篇常见错排查
流式链路的报错往往不直观,我把调试过程中遇到的高频问题整理成表,方便你对照。
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 首字延迟超过 15 秒后报超时 | first_token_timeout设太短,或模型冷启动慢 | 临时调到 30 秒观察,若正常则说明是模型侧慢 |
| 内容一次性全部返回 | provider.stream为 false,或中间层开了缓冲 | 检查配置和反向代理的proxy_buffering |
流到一半报unexpected EOF | 服务端未发[DONE]就关闭,或网络抖动 | 看max_reconnect是否触发,日志里应有重连记录 |
| 代码块被从中间切断 | chunk算法未识别代码块边界 | 确认boundary_priority包含code_block |
| Telegram 消息更新卡顿 | edit_debounce_ms太小导致触发频率限制 | 调到 100ms 以上,观察是否流畅 |
| 重连后内容重复 | 服务端不支持Last-Event-ID,从头开始发 | 在客户端做去重,或换支持续传的通道 |
其中“内容一次性全部返回”是最常见的,八成是stream没开或者中间有缓冲层。我踩过的坑是本地加了个 Nginx 做转发,忘了关proxy_buffering,结果 SSE 被 Nginx 攒成一坨才吐出来,排查了半天才定位到。如果你也加了反向代理,记得在对应 location 里加上proxy_buffering off;和proxy_cache off;。
另一个容易忽略的是chunk.max_length和平台限制的关系。如果你接入的是 Discord(2000 字符限制),却把max_length设成 3800,切割后的消息发出去会被平台拒绝。这种情况下要么调小max_length,要么在输出层做平台适配,把通用 Markdown 转成目标平台能接受的格式和长度。
6. 把流式链路跑稳之后
流式输出这条链路,配通只是第一步,跑稳才是关键。我建议你在本地至少完整跑三遍:一遍正常流,一遍模拟断流重连,一遍长回复触发多次 Chunk 切割。三遍都过了,再接到真实的消息平台上。
如果你在验证过程中遇到 Key 或通道层面的问题,可以直接去 API Keys 页面重新生成一个 Key 对比测试,排除是 Key 本身的问题。接入细节和字段说明在接入文档里有完整列表,配置项对不上时优先查那里。想先直观感受一下流式返回的块结构,可以用模型对话页面发一条消息,打开浏览器开发者工具看 Network 里的 SSE 流,对照本文的 Block 状态机理解每个事件的含义。
长期在本地跑编码类 Agent、需要频繁调用流式接口的话,Coding Plan 的额度模型比按次计费更适合高频调试场景,你可以根据自己的调用量算一下。流式链路调通之后,下一步就是多 Agent 路由——当一个 Gateway 要同时托管多个 Agent 时,消息怎么分发到正确的那个,那是另一个值得单独拆的话题。