Blog posts and course content are currently in Chinese. Translation is in progress.
Back to Blog

长推理跑 10 分钟就断?聊聊 LLM 流式代理里那条 15 秒一次的心跳

2026年8月24日45 min read
LLM 流式代理心跳与长上下文

为什么 agent 跑长任务会被 idle timeout 误杀?为什么日志里 'LLM idle timeout (120s): no response from model' 出现时上游其实没事?源码逐行:管道心跳、上下文自动截断、4xx 错误透传上游 body。

跑 agent 的同学大概率撞过这个错:

LLM idle timeout (120s): no response from model

看着像是模型卡了,可你切到上游供应商控制台一看——人家吐了几 KB 的 token,没卡。错杀。这篇扒一下 OpenClaw LLM 代理(src/proxy/)里处理 SSE 流式长请求的三道防线,每一道都对得上一个具体的"为啥会断"场景。文末会给你一段你自己的代理可以照搬的最小心跳实现。

一、流式请求的判定:先决定走哪条管道

第一道在 src/proxy/llm-proxy.service.ts:3758-3765

const resolvedResponseType =
  requestOptions.responseType ||
  ((apiEndpoint === '/chat/completions' || apiEndpoint === '/responses') &&
    compatibleBody?.stream === true
    ? 'stream'
    : 'json');

判定条件三个 AND:① API 端点必须是 /chat/completions/responses,② 请求体里 stream: true,③ 控制器层没显式指定 responseType

第三条容易踩坑——控制层 (llm-proxy.controller.ts:1158) 是这样判的:

const isStreaming = method === 'POST' && body?.stream === true;

只判了 method === 'POST' && body.stream没判端点。所以 GET /models + stream: true 的请求在这层会走 SSE,但在 service 层又被强制判成 'json',整段上游 SSE 字符串被塞进 response.data,下游合成事件时把整段字符串当对象字段——日志里看到一堆 data: data: data: 堆叠就是这种。

怎么避免:客户端要么别在 GET 请求里塞 stream: true,要么 service 层走显式 responseType: 'stream' 透传。

为什么 stream 必须走独立 axiosRef 管道llm-proxy.service.ts:3810-3820):

const axiosInstance = this.httpService.axiosRef;
const response =
  resolvedResponseType === 'stream'
    ? await axiosInstance({ ..., responseType: 'stream' })
    : await firstValueFrom(this.httpService.request({ ..., responseType: resolvedResponseType }));

firstValueFrom 是 rxjs 的 take(1),一旦它取到第一个值就自动 unsubscribe 取消底层 HTTP 请求。流式请求我们要的是「持续读」,不是「取第一个值」,所以必须绕开 firstValueFrom,直接拿 axiosRefresponseType: 'stream' 传进去——否则流刚出第一个 chunk 整个 socket 就被 rxjs 关了。

二、15 秒一次的心跳:让 idle timeout 闭嘴

第二道在 src/proxy/llm-proxy.controller.ts:277-306pipeWithKeepalive

private pipeWithKeepalive(upstream: Readable, res: Response, intervalMs: number = 15_000) {
  let lastByteAt = Date.now();
  const ping = () => {
    if (res.writableEnded || res.destroyed) return;
    if (Date.now() - lastByteAt >= intervalMs) {
      try {
        res.write(': ping\n\n');  // SSE 注释帧
      } catch { /* ... */ }
    }
  };
  const timer = setInterval(ping, intervalMs);
  const stop = () => clearInterval(timer);

  upstream.on('data', () => { lastByteAt = Date.now(); });
  upstream.once('end', stop);
  upstream.once('error', stop);
  upstream.once('close', stop);
  res.once('close', stop);
  res.once('finish', stop);

  upstream.pipe(res);
}

4 个关键点

  1. 写的是 : ping\n\n,不是 data: {...}\n\n。SSE 规范里 : 开头的行是注释,浏览器/客户端 EventSource 协议栈会静默丢掉,不进 onmessage 回调,但足以让 TCP/HTTP 链路上的 idle timer 重置——这是绕 idle timeout 的标准招式。

  2. 15s 间隔是经验值。太小(< 5s)烧带宽且频繁打断流式事件聚合;太大(> 30s)容易让 nginx/cloud LB 的默认 60s 空闲超时先触发。15s 落在大多数云 LB 默认值的一半以内,安全。

  3. 5 个 listener 都要清 timer。上游 end/error/close、下游 close/finish,任何一个先触发都得 clearInterval,否则事件都 end 了 timer 还在每 15s 写 : ping\n\n 到一个已 end 的 socket——Node 会 ERR_STREAM_WRITE_AFTER_END 一直刷错误日志。

  4. lastByteAt 只在 data 事件里更新。这样心跳的写入不会重置 idle 计时——只有真正的上游字节才计入"有进展"。

调用点 5 处llm-proxy.controller.ts 728 / 770 / 814 / 856 / 1187,覆盖了 /v1/chat/completions/v1/responses、直接实例代理三条主路径的 SSE 出口。这段是 commit 703bc79b9(2026-07-14)加的,master 上已有一个多月。

你自己的代理可以照搬这段——大约 30 行。代价是上游每沉默 15s 会多一次 6 字节的 : ping\n\n 写;收益是 10 分钟的长推理不会因为 LB 误判空闲被掐。

三、长上下文兜底:90 万 token 也得砍

第三道在 llm-proxy.service.ts:4193 的调用 + 4996-5073 的实现 truncateLargeContext

this.truncateLargeContext(body, apiEndpoint, instanceId);

参数看得很直白——超过 10 万 token 粗估 + 80 条消息以上才生效,单条大消息不动(交给上游报 400 让人看见):

const MAX_CONTEXT_TOKENS = 100_000;       // 100K token 触发
const KEEP_RECENT_MESSAGES = 60;          // 保留最近 60 条非 system
const MIN_MESSAGES_TO_TRUNCATE = 80;      // 总数不到 80 不动

为什么是 100K 不是 200K:火山方舟 128K 上下文窗口,留 28K 给 system prompt + tools + 输出。如果你用 200K 触发,到上游还是会被挤爆——兜底要兜到安全线以下。

截断策略5048-5068)很关键:

const keptConversation = conversationMessages.slice(-KEEP_RECENT_MESSAGES);
// 确保截断后第一条对话是 user(Anthropic/多数模型要求 user 起头)
while (keptConversation.length > 0 && keptConversation[0]?.role !== 'user') {
  keptConversation.shift();
}
const truncationNotice = {
  role: apiEndpoint === '/messages' ? 'user' : 'system',
  content:
    '[系统提示] 为控制上下文长度,更早的对话历史已被自动省略,仅保留最近的对话。',
};
  • OpenAI /chat/completions[...systemMessages, notice, ...keptConversation]——system 消息全留(agent 的工具说明通常在 system 里),notice 插在 system 之后。
  • Anthropic /messages[notice, ...keptConversation]——Anthropic 的 system 字段在 body 顶层,messages 数组里没有 system 角色,所以 notice 只能塞进 messages。
  • 首条必须是 user:Anthropic API 严格要求 messages[0].role === 'user',否则 400 invalid_request_error。OpenAI 没这硬性要求但多数模型也按这个走。while 循环把首条 user 之前的 assistant/tool 全部砍掉——这是直接搬上游规则的,没在文档里写。

这层为什么必要:OpenClaw agent 的 compaction 是异步的,某些 runtime 不会主动压缩,单实例跑 1 小时能攒 500+ 条消息(实测单次请求 90 万 token),不截断就 400。所以这层是 backend 兜底,不是替代——agent 自己的 compaction 才是正经解。

配置位置:这俩常量是写死在方法里的(5007-5009),不是 instance 配置。如果想暴露给运营调,得提到 customLLMConfig.config.truncation

四、4xx 错误透传:让 agent 看到上游原话

第四道是 8-15 那轮 commit e585c9401 之后的姊妹修补——这次 8-24 的本地改动 llm-proxy.service.ts:5172-5208

} else {
  // 4xx 不重试(见 model-router isRetryable),所以这里是 agent 看到的
  // 最终错误。上游 400 的 body 不一定有 error.message 字段,若直接兜底成
  // 常量 'LLM 请求失败',排查时只能拿到一句无信息量的中文,必须回服务器
  // 日志里捞——heartbeat/agent 场景下往往捞不到。
  const upstreamDetail =
    data?.error?.message ||
    data?.message ||
    (data ? JSON.stringify(data).slice(0, 500) : '');
  const errorMessage = upstreamDetail
    ? `LLM 请求失败 (${status} from upstream): ${upstreamDetail}`
    : `LLM 请求失败 (${status} from upstream,上游未返回错误详情)`;
  throw new HttpException(errorMessage, status || HttpStatus.BAD_GATEWAY);
}

之前的代码:const errorMessage = data?.error?.message || data?.message || 'LLM 请求失败';——上游 400 body 里 error.message 字段不一定有(OpenAI 有、火山有,部分小供应商没有),没有就退化成一句"LLM 请求失败"。

修完后:兜底到 JSON.stringify(data).slice(0, 500),拼上 (400 from upstream): 前缀。agent 端不再需要回到服务器日志捞——错误里直接带诊断信息。

类似的还有 5xx 之外的兜底分支(5196-5209):

} else {
  this.logger.error(`Unexpected error when contacting LLM service: ${error.message}`);
  // 同上:带上 axios code / message,否则这条分支的 502 无法与上游 4xx 区分。
  throw new HttpException(
    `LLM 请求失败 (${error.code || 'no response'}): ${error.message || '未知错误'}`,
    HttpStatus.BAD_GATEWAY,
  );
}

ECONNRESET(网络层错)和上游 400(业务层错)现在分别带不同的 code 前缀,能直接区分。

五、为什么这四道缺一不可

一个长推理请求从客户端到上游,会经过 4 类不同的 idle/timeout 杀手:

杀手在哪一层触发条件我们的解
cloud LB idle (60s)链路层TCP 60s 无数据pipeWithKeepalive 15s 一次注释帧
agent runtime idle (120s)应用层SSE 120s 无 data 帧同上,注释帧也算"事件"
agent 上下文累积应用层messages > 80 + token > 100KtruncateLargeContext 砍到 60
上游 4xx / 网络 5xx业务层错误信息丢upstreamDetail 透传 + 拼 status

任意一道缺了:① 没心跳 → 5 分钟推理被 LB 掐;② 没截断 → 单次请求 90 万 token 烧光余额;③ 没 4xx 透传 → 出问题只能去捞日志(heartbeat 场景捞不到)。

六、你自己写代理的最小可行版本

如果你不想用 NestJS,把这三段拼起来就够撑住 90% 的场景:

// 1. 心跳:15s 注释帧
function pipeWithKeepalive(upstream, res, intervalMs = 15_000) {
  let lastByteAt = Date.now();
  const ping = setInterval(() => {
    if (res.writableEnded) return clearInterval(ping);
    if (Date.now() - lastByteAt >= intervalMs) res.write(': ping\n\n');
  }, intervalMs);
  upstream.on('data', () => { lastByteAt = Date.now(); });
  upstream.on('end', () => clearInterval(ping));
  upstream.on('error', () => clearInterval(ping));
  res.on('close', () => clearInterval(ping));
  upstream.pipe(res);
}

// 2. 上下文截断(OpenAI 版)
function truncate(body, keep = 60, maxTok = 100_000) {
  if (!Array.isArray(body.messages) || body.messages.length < 80) return body;
  const total = body.messages.reduce((s, m) => s + JSON.stringify(m.content ?? '').length / 3, 0);
  if (total < maxTok) return body;
  const sys = body.messages.filter(m => m.role === 'system');
  const conv = body.messages.filter(m => m.role !== 'system').slice(-keep);
  while (conv.length && conv[0].role !== 'user') conv.shift();
  body.messages = [...sys, { role: 'system', content: '[已截断] 早前历史已省略。' }, ...conv];
  return body;
}

// 3. 4xx 透传
function fmtUpstreamError(status, data) {
  const detail = data?.error?.message || data?.message || JSON.stringify(data).slice(0, 500);
  return `LLM 请求失败 (${status} from upstream): ${detail || '上游未返回错误详情'}`;
}

外加一个最容易被忽略的:axios 拿到的 stream 不要丢给 rxjs 的 firstValueFrom,否则第一个 chunk 一回包 socket 就关。直接 axios({ responseType: 'stream' }) 返回的 response.data 就是 Node.js Readable,可以直接 pipe。

七、这些改动什么时候上生产

本文 8-24 写的当下,本地 src/proxy/llm-proxy.service.ts:5172-5208 的 4xx 透传还在未提交(uncommitted)。生产 build 指纹自 8-16 起是 CwPzfuVw0qkuzRrzGnLAM,意味着最近 8 天的所有 backend 改动(4xx 透传 / 上下文截断 / 视频生成多供应商 / 模型路由熔断 / 一些图片生成兜底)都还没 deploy。pipeWithKeepalive 是 8-08 之前就合 master 的,生产有——长推理不掐是这个兜底。

如果你撞到 LLM 请求失败 (400 from upstream): ... 但 OpenClaw 控制台日志查不到具体内容,先确认生产 buildId 是不是 8-16 之后——老构建会拿不到透传 detail。

文档版本基于 2026-08-24 的 src/proxy/llm-proxy.service.ts + src/proxy/llm-proxy.controller.ts