attemptApiRequest:流式 LLM 调用与重试
职责
attemptApiRequest 是 Task 里负责「跟 LLM provider 通信」那一段的生成器 (generator) 方法。它是一个 async * 函数,yield 出一个个 stream chunk 给上层 recursivelyMakeClineRequests 消费。在 yield 之前,它要做一大堆准备工作:等 MCP 服务器连上、读规则文件、拼系统提示、做上下文截断 (context truncation);在第一个 chunk 出错时,它要决定是自动重试、是问用户、还是直接放弃。
它的位置在 agent loop 的中层。recursivelyMakeClineRequests 每递归一次就调 attemptApiRequest 一次,把流拿到后由外层 while 循环消费 chunk、解析、调 presentAssistantMessage。所以这个方法本身不消费流,它只负责「把流准备好并安全地交出去」。
它的输出类型是 ApiStream,本质是一个异步迭代器。上层先用 iterator.next() 拿第一个 chunk 试水 (first chunk probe:2389)。第一个 chunk 成功就 yield* iterator 把剩余全部转手;失败就走错误分类与重试逻辑。这种「先拿一个 chunk 探活」的设计,把「流前失败」和「流中失败」分开处理,因为前者状态干净,可以无副作用重试,后者可能已经执行了部分工具,不能简单重试。
设计动机
- MCP 等待有上限:用
pWaitFor等mcpHub.isConnecting转假,超时 10 秒就记个 error 继续往下走 (mcp wait:2177)。不让 MCP 卡死整个请求。 - SystemPromptContext 一锅烩:把 cwd、IDE、provider 信息、规则文件、clineignore、技能、editor tabs、parallel tool calling 等全塞进一个 context 对象 (
promptContext:2300)。getSystemPrompt一次性消费,避免 prompt 拼接逻辑散落。 - 上下文截断在调用前:
contextManager.getNewContextMessagesAndMetadata负责把对话历史裁到模型上下文窗口内 (context truncate:2354)。如果裁剪过程中发现需要更新删除范围,立刻写回 clineMessages 持久化。 - 首 chunk 探活策略:用
isWaitingForFirstChunk标志 + 单独 try/catch 包住iterator.next(),首 chunk 失败时状态还是干净的,可以安全重试 (first chunk try:2388)。 - 错误分类决定重试:auth、spend limit、quota、entitlement、ClinePass 限额、insufficient credits 这些「重试也没用」的错误跳过自动重试 (
shouldRetry gate:2495);其他错误最多自动重试 3 次,延迟 2s/4s/8s 指数退避 (backoff:2509).
关键文件
attemptApiRequest:2175— 方法主体,从 MCP 等待到yield*全在这。pWaitFor mcpHub:2177— 等 MCP 服务器连上,10 秒超时。SystemPromptContext:2300— 把所有 prompt 输入收集到一个对象,准备给getSystemPrompt。getSystemPrompt:2350— 生成最终 systemPrompt 和 tools 数组。getNewContextMessagesAndMetadata:2354— 上下文管理器裁剪对话历史,返回截断后的消息和元数据。deleted range update:2366— 裁剪更新了删除范围时,立刻持久化到 clineMessages。createMessage:2378— 用 api handler 创建流,传 systemPrompt、truncated history、tools。first chunk probe:2388— 单独 try/catch 拿第一个 chunk,失败走错误分类。context window check:2393—checkContextWindowExceededError判断是不是上下文超长。handleContextWindowExceededError:2409— 上下文超长自动截断并重试一次。shouldRetry gate:2495— 排除掉重试无意义的错误类型。exponential backoff:2509— 2s/4s/8s 三次自动重试,递增延迟。yield* recurse:2620— 重试成功后递归调用自身,把新流的 chunk 转手给上层。
数据流
请求从准备到 yield 的主路径是「MCP 等 → 拼 prompt → 截断历史 → 创建流 → 探活 → 转手」。截断历史这一步是关键,它决定模型实际看到什么:
// apps/vscode/src/core/task/index.ts
const contextManagementMetadata =
await this.contextManager.getNewContextMessagesAndMetadata(
this.messageStateHandler.getApiConversationHistory(),
this.messageStateHandler.getClineMessages(),
this.api,
this.taskState.conversationHistoryDeletedRange,
previousApiReqIndex,
await ensureTaskDirectoryExists(this.taskId),
this.stateManager.getGlobalSettingsKey("useAutoCondense") &&
isNextGenModelFamily(this.api.getModel().id),
);
if (contextManagementMetadata.updatedConversationHistoryDeletedRange) {
this.taskState.conversationHistoryDeletedRange =
contextManagementMetadata.conversationHistoryDeletedRange;
await this.messageStateHandler.saveClineMessagesAndUpdateHistory();
// saves task history item which we use to keep track of conversation history deleted range
}ContextManager 拿完整对话历史 + 上次记录的删除范围 + 上次请求的 token 用量,算出新的截断范围。如果范围有变化,立即把 clineMessages 写盘——因为下一次请求要依赖这个新范围。useAutoCondense 是新模型 (next-gen) 专属的自动压缩开关,开启后会用模型自己生成摘要替代早期消息 (autocondense flag:2362)。
截断完成之后才创建流并探活:
// apps/vscode/src/core/task/index.ts
const stream = this.api.createMessage(
systemPrompt,
truncatedConversationHistory,
tools,
);
const iterator = stream[Symbol.asyncIterator]();
try {
// awaiting first chunk to see if it will throw an error
this.taskState.isWaitingForFirstChunk = true;
const firstChunk = await iterator.next();
yield firstChunk.value;
this.taskState.isWaitingForFirstChunk = false;
} catch (error) {
// ... 错误分类、自动重试、或问用户
yield* this.attemptApiRequest(previousApiReqIndex);
return;
}isWaitingForFirstChunk 标志告诉外层「我现在在等首 chunk,你别把我当流中状态处理」。首 chunk 失败的 catch 分支按错误类型分流:context window 超长且没自动重试过就调 handleContextWindowExceededError 自动截断重试;其他错误根据 shouldRetry 判断走自动退避重试或者 ask("api_req_failed") 把决定权交给用户。
边界与失败
- MCP 超时不致命:MCP 10 秒连不上只记 error,继续往下走 (
mcp timeout catch:2179)。后续 system prompt 里 MCP 工具可能就缺失,但请求不会因此失败。 - 上下文超长只自动重试一次:
didAutomaticallyRetryFailedApiRequest标志保证handleContextWindowExceededError只调一次 (auto retry flag:2407)。第二次还超长就改走ask("api_req_failed"),把决定权交给用户。 - 对话已经很短还超长:如果截断后消息数 ≤ 3,直接告诉用户「context window exceeded, retry to truncate」但不再自动截断 (
conversation bricked:2423)。这种情况对话基本废了,得用户介入。 - api_req_started 状态更新:每次重试都会找最后一个
api_req_started消息,更新它的streamingFailedMessage和retryStatus(update api_req_started:2433)。UI 靠这个字段展示「重试中」「重试耗尽」状态。 - error_retry 双重展示去重:自动重试时会先
say("error_retry", ...)完整信息,然后从api_req_started里删掉streamingFailedMessage防止同一个错误在 ErrorRow 和 error_retry 里同时显示 (dedupe error display:2548)。 - 重试计数器手动清零:用户点 yes 重试时把
autoRetryAttempts清零 (reset counter:2586),给后续失败再留 3 次自动重试空间。 - 首 chunk 之后失败不在这里处理:首 chunk 成功后续流中失败由
recursivelyMakeClineRequests的外层 try/catch 接管 (stream-mid failure:3935)。这里只管「流开始之前」的失败。
小结
attemptApiRequest 是「准备 + 探活 + 重试」三件事的合体。准备阶段把规则、技能、MCP、上下文裁剪都跑一遍,探活阶段用首 chunk 决定要不要重试,重试阶段用错误分类 + 指数退避 + 用户兜底三层兜底。流一旦开始就被它 yield* 出去不归它管了。要看流出去之后怎么被消费、怎么切成 block 推进,转 /agent-loop/present-assistant-message;要看更外层的递归驱动,转 /agent-loop/task-class。