为什么 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,直接拿 axiosRef 把 responseType: 'stream' 传进去——否则流刚出第一个 chunk 整个 socket 就被 rxjs 关了。
二、15 秒一次的心跳:让 idle timeout 闭嘴
第二道在 src/proxy/llm-proxy.controller.ts:277-306 的 pipeWithKeepalive:
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 个关键点:
-
写的是
: ping\n\n,不是data: {...}\n\n。SSE 规范里:开头的行是注释,浏览器/客户端 EventSource 协议栈会静默丢掉,不进onmessage回调,但足以让 TCP/HTTP 链路上的 idle timer 重置——这是绕 idle timeout 的标准招式。 -
15s 间隔是经验值。太小(< 5s)烧带宽且频繁打断流式事件聚合;太大(> 30s)容易让 nginx/cloud LB 的默认 60s 空闲超时先触发。15s 落在大多数云 LB 默认值的一半以内,安全。
-
5 个 listener 都要清 timer。上游
end/error/close、下游close/finish,任何一个先触发都得clearInterval,否则事件都 end 了 timer 还在每 15s 写: ping\n\n到一个已end的 socket——Node 会ERR_STREAM_WRITE_AFTER_END一直刷错误日志。 -
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 > 100K | truncateLargeContext 砍到 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。