返回博客

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

2026年8月24日阅读约 45 分钟
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