← 返回 2026-09-21 简报

在 OpenRouter 上构建可靠的工具调用智能体循环

语音播报
摘要
事件:OpenRouter发布指南,展示如何用TypeScript SDK构建可靠的工具调用智能体循环,无需AI框架即可实现核心控制流。 要点:每次请求需附带完整消息历史和工具定义;通过模型列表实现有序回退;设置迭代上限防止死循环,默认使用Auto Exacto重排提供商。 影响:开发者可轻量级实现复杂交互逻辑,降低对重型Agent框架的依赖,提升系统稳定性与可控性,加速应用落地。

工具调用代理循环会反复将对话历史和可用工具发送给模型,执行模型请求的任何工具调用,追加结果,并询问模型下一步该做什么。当模型不再返回任何工具调用,或触发应用程序定义的中止条件时,循环结束。

由模型决定请求哪个工具,但你的应用程序负责解析参数、运行函数,并决定何时停止循环。

本指南展示了如何使用 OpenRouter TypeScript SDK 构建该循环。示例使用了本地天气数据,因此你可以在无需设置其他服务的情况下运行它。

简而言之

模型请求工具。你的应用程序执行这些工具调用,并为每个工具调用 ID 返回一个结果。

在每次请求中发送工具定义,包括收到工具结果后的后续请求。

当模型不再返回工具调用、同一调用重复次数超过限制或达到迭代上限时停止。

models 参数为你提供有序的模型回退机制,而 Auto Exacto 默认会在工具调用请求中对提供商进行重新排序。

设置任务、工具和消息历史,然后重复以下步骤:

使用完整的历史记录和工具定义调用模型。

如果响应中没有工具调用,返回助手的文本并停止。

如果这是最后一次允许的迭代,请在运行工具之前停止。因为它们的結果可能永远无法送达模型。

如果某次调用重复次数过多,则停止。

否则,运行每个工具调用,并追加助手消息以及每个调用的一个结果。

该循环还需要一个硬性限制。模型可能会重复失败的调用,或不断寻找更好的答案。如果没有迭代上限,这种行为可能会持续下去,直到系统的其他部分将其停止。

你不需要 AI 代理框架来构建或理解这种控制流。即使你之后将循环移入库中,小型实现也依然有用。

创建 TypeScript 项目并安装 OpenRouter TypeScript SDK 和 tsx:

mkdir openrouter-agent-loop
cd openrouter-agent-loop
npm init -y
npm pkg set type=module
npm install @openrouter/sdk
npm install --save-dev tsx

创建 OpenRouter API 密钥,然后使其对进程可用:

export OPENROUTER_API_KEY="your-api-key"

创建 agent.ts 并添加客户端、有序的模型列表以及一个本地工具:

import { OpenRouter } from "@openrouter/sdk" ;
import type { ChatMessages, ChatToolCall } from "@openrouter/sdk/models" ;

if ( ! process.env. OPENROUTER_API_KEY ) {
throw new Error ( "Set OPENROUTER_API_KEY before running this example" );
}

const openRouter = new OpenRouter ({
apiKey: process.env. OPENROUTER_API_KEY ,
});

const models = [
"google/gemini-3-flash-preview" ,
"nvidia/nemotron-3.5-lightning" ,
];

const tools = [
{
type: "function" as const ,
function: {
name: "get_weather" ,
description: "获取拉各斯或伦敦的本地示例天气数据" ,
parameters: {
type: "object" ,
properties: {
city: { type: "string" , enum: [ "Lagos" , "London" ] },
},
required: [ "city" ],
additionalProperties: false ,
},
},
},
];

const weatherByCity : Record <
string ,
{ temperatureC : number ; conditions : string }

= {
lagos: { temperatureC: 29 , conditions: "partly cloudy" },
london: { temperatureC: 18 , conditions: "overcast" },
};

工具定义告诉模型何时使用该函数以及它接受哪些参数。函数本身保留在你的应用程序内部,因为只有你的代码才能运行它。

保持描述具体,并包含模型正确使用工具所需的详细信息。在这里,命名两个支持的城市有助于模型生成有效的参数。

列表中的两个模型都支持工具调用。模型的可用性会随时间变化,因此在挑选自己的模型之前,请查看模型目录以获取当前支持 tools 参数的模型列表。

步骤 2:调用模型并读取响应

接下来,添加一个辅助函数,用于发起一次模型请求并返回助手消息以及任何工具调用:

async function sendTurn (
messages : ChatMessages [],
toolChoice : "required" | "auto" ,
) {
const result = await openRouter.chat. send ({
chatRequest: {
models,
messages,
tools,
toolChoice,
maxCompletionTokens: 1024 ,
stream: false ,
},
});

if ( ! ( "choices" in result)) {
throw new Error ( "Expected a non-streaming response" );
}

const message = result.choices[ 0 ]?.message;

if ( ! message) throw new Error ( "The model returned no message" );

return {
result,
message,
calls: message.toolCalls ?? [],
};
}
SDK 将请求体嵌套在 chatRequest 下,并使用 camelCase 字段,如 toolChoice、maxCompletionTokens 和 toolCalls。它会将这些字段转换为 API 的 snake_case 传输格式,因此 toolChoice 会被发送为 tool_choice 。

在每次调用中保留 tools,包括后续调用。我们会根据这些定义验证返回的工具调用,模型需要这些信息来决定是否可以使用其他工具。

第一次迭代使用 toolChoice: "required",以便示例始终执行工具路径。后续迭代使用 "auto",这允许模型在收到工具结果后返回最终答案。如果每次迭代都强制要求工具,模型将无法通过文本来完成响应。这是示例做出的选择,而非 API 的默认行为。当存在 tools 时,API 中 tool_choice 的默认值为 "auto"。

添加一个辅助函数来执行单个工具调用,并创建你将发回给模型的 tool 消息:

async function executeToolCall ( call : ChatToolCall ) : Promise < ChatMessages > {
let content : string ;

try {
if (call.function.name !== "get_weather" ) {
throw new Error ( Unknown tool: ${ call . function . name } );
}

const args = JSON . parse (call.function.arguments) as { city ?: unknown };

if ( typeof args.city !== "string" ) {
throw new Error ( "city must be a string" );
}

const weather = weatherByCity[args.city. toLowerCase ()];

if ( ! weather) {
throw new Error ( No weather data for ${ args . city } );
}

content = JSON . stringify ({ city: args.city, ... weather });
} catch (error) {
content = JSON . stringify ({
error: error instanceof Error ? error.message : String (error),
});
}

return {
role: "tool" ,
toolCallId: call.id,
content,
};
}
工具参数以 JSON 字符串形式到达,因此 JSON.parse() 应放在 try 块内部。无效的 JSON、未知的函数名或失败的处理器都会变成工具结果,而不是导致循环崩溃。然后模型可以更改其参数、选择另一个工具或解释失败原因。

结果还携带原始调用 ID 作为 toolCallId 。这是模型将每个结果与请求它的调用进行匹配的方式。一个响应可能包含多个调用,每个调用都需要自己的结果。

此示例向模型返回预期的解析和执行失败。身份验证、网络和其他请求级别的错误仍应跳出循环,以便周围的应用程序处理它们。

步骤 4:在受限循环中包装调用

步骤 2 和 3 处理一次模型交互。添加 runAgent() 以连接它们:

async function runAgent ( task : string , maxIterations = 10 ) {
if ( ! Number. isSafeInteger (maxIterations) || maxIterations < 1 ) {
throw new Error ( "maxIterations must be a positive safe integer" );
}

const messages : ChatMessages [] = [{ role: "user" , content: task }];
const callCounts = new Map < string , number >();

for ( let iteration = 1 ; ; iteration ++ ) {
const startedAt = performance. now ();
const { result , message , calls } = await sendTurn (
messages,
iteration === 1 ? "required" : "auto" ,
);

console. info ({
iteration,
model: result.model,
tools: calls. map (( call ) => call.function.name),
latencyMs: Math. round (performance. now () - startedAt),
});

if (calls. length === 0 ) {
return typeof message.content === "string" ? message.content : null ;
}

if (iteration === maxIterations) {
throw new Error ( Stopped after ${ maxIterations } iterations );
}

for ( const call of calls) {
const fingerprint = ${ call . function . name }:${ call . function . arguments } ;
const count = (callCounts. get (fingerprint) ?? 0 ) + 1 ;

callCounts. set (fingerprint, count);

if (count >= 3 ) {
throw new Error (
Stopped after three identical calls to ${ call . function . name } ,
);
}
}

messages. push (message);
messages. push ( ... ( await Promise . all (calls. map (executeToolCall))));
}
}
每次循环都会调用模型,检查其是否已完成,然后追加助手消息和工具结果。下一次循环会将该扩展后的历史记录通过 sendTurn() 发送回去。在工具结果之前追加助手消息,以保留完整的交互轮次。

迭代上限是在模型响应之后、任何工具执行之前进行检查的。工具结果仅在下一次模型请求中才有用,因此当模型在最后一次允许的迭代中仍请求工具时,循环会停止执行这些工具。如果在最后一步执行它们,会产生模型永远无法看到或报告的副作用。

该上限是循环中唯一的硬性限制,且检查是将迭代次数与 maxIterations 进行相等比较。runAgent() 会在第一次请求之前拒绝非正安全整数的上限值,因为像 0 、 1.5 或 NaN 这样的值永远不会匹配,导致循环一直运行直到模型停止请求工具。Number.isSafeInteger() 还会拒绝高于 Number.MAX_SAFE_INTEGER 的值,因为在这些值上 iteration++ 不再产生不同的值,从而永远无法达到上限。

指纹计数器会在相同的工具和参数字符串出现三次后停止运行。两次重复会阻止在空或临时结果后进行一次重试的模型。三次允许那一次重试,同时仍能快速停止卡住的模型。该阈值和 maxIterations 的默认值是应用程序的选择,而非 OpenRouter 的默认值。

在生产应用中,在比较之前规范化解析后的参数,以便重新格式化但相同的调用仍被视为重复。您还可以为每个工具设置不同的限制。

测试循环

用一个小 main() 函数结束 agent.ts:

async function main () {
const answer = await runAgent (
"Compare the weather in Lagos and London. Which city is warmer?" ,
);

console. log (answer);
}

main (). catch (( error ) => {
console. error (error);
process.exitCode = 1 ;
});
从终端运行它:

npx tsx agent.ts
你应该会看到一次或多次调用 get_weather 的迭代,随后是一次最终的比较。这些值来自第一步中的本地映射,因此你可以在将处理器替换为真实服务之前测试完整的循环。这是针对 API 的一次运行的输出:

{
iteration: 1,
model: 'google/gemini-3-flash-preview',
tools: [ 'get_weather', 'get_weather' ],
latencyMs: 3930
}
{
iteration: 2,
model: 'google/gemini-3-flash-preview',
tools: [],
latencyMs: 1417
}
拉各斯目前比伦敦更温暖。
第一次迭代在一个响应中返回了两个 get_weather 调用,循环并行执行了这些调用。第二次迭代没有返回任何工具调用,因此循环返回了文本。你的运行可能在迭代次数、答案措辞和延迟方面有所不同。

添加回退和并发控制

这里的每个控制都处理不同的故障模式。迭代和重复限制停止应用程序内部无益的行为。你的并发选择防止并行工具相互破坏。模型回退处理所选模型的错误。Auto Exacto 在工具调用请求上更改提供商顺序。

从循环中已有的停止机制开始。当模型不发送任何工具调用时返回,当相同的调用出现三次时失败,当模型在迭代 maxIterations 时仍请求工具时失败。即使你稍后添加时间、令牌或特定于工具的限制,也要保留硬性上限。

并发是下一个选择。该示例使用 Promise.all() 运行工具调用,因为这两个查询是相互独立的。除非你已确认工具之间不共享状态,否则应按顺序执行调用。对同一记录的写入操作与随后的读取操作绝不能并行运行。

模型回退是下一层。models 参数接受一个有序列表。第一个模型是首选,当第一个模型返回错误(包括速率限制、提供商停机以及内容审核拒绝)时,我们会尝试列表中的下一个模型。你的循环通过读取 result.model 来查看是哪个模型做出了响应。

Auto Exacto 默认在包含工具的每个请求上运行。对于由多个提供商提供的模型,我们根据吞吐量、工具调用成功率和基准测试框架的结果重新排列这些提供商,而不是依据价格。你的循环无需更改即可启用此功能。由单个提供商提供的模型没有需要重新排列的内容,因此请查看模型页面上的提供商列表,以确认重新排列是否适用于你所选的模型。如果你的循环在每一轮都发送大型工具定义,且对你而言提示缓存命中率比提供商质量更重要,你可以通过将 provider.sort 设置为 "price" 或使用 :floor 模型变体来退出此功能。

限制历史增长并记录每次迭代

每次迭代都会添加助手消息以及每个工具调用的一条结果。这些消息会在后续请求中再次发送,因此大型工具结果可能导致历史记录迅速膨胀。

如果工具返回大型响应且模型仅需少数字段,请在添加结果之前选择这些字段。你也可以截断内容,或将完整负载存储在其他位置并返回引用。请确保截断后的结果仍然有效。例如,返回一个包含预览、原始大小和截断标志的 JSON 对象,而不是在中间切片 JSON 字符串。

循环已经记录了迭代、返回的模型、工具名称和延迟。在应用程序中,添加参数的安全哈希值和停止原因。不要记录密钥或原始敏感参数。简短且一致的追踪可以显示运行何时开始重复或达到上限。

Model Context Protocol 服务器(即 MCP 服务器)暴露来自其他服务或进程的工具。你的循环仍然向模型发送工具定义、接收调用、执行调用并追加结果。区别在于,MCP 客户端负责工具发现和执行,而不是你本地的函数映射。

当你拥有少量函数并希望实现最简方案时,使用本地处理器。当工具已经存在于远程服务器(如 GitHub、Linear 或内部服务)之后时,使用 MCP。

相同的边界仍然适用。为每个工具调用 ID 返回一个结果,在后续请求中保持工具可用,检测重复并强制执行上限。MCP 改变了工具的运行位置。它并没有消除对这些控制的需求。

何时迁移到 Agent SDK

当你希望库来管理多轮循环、工具执行、对话状态和停止条件时,请迁移至我们的 Agent SDK。它还支持 MCP 工具、流式传输、工具批准和状态持久化,以及用于检测重复工具调用的死循环检测功能(默认关闭)。

如果你的工具已经存在于远程 MCP 服务器之后,@openrouter/mcp 可以发现它们并将它们暴露给 Agent SDK 的 callModel 函数,与你的本地工具并列。

当你希望对消息、工具分发和停止条件拥有直接控制权时,自行构建循环仍然很有用。它还为你提供了一种具体方式来理解代理框架为你管理的内容。

当你的应用程序需要在对话之外具备持久工作流或持久状态时,更大的编排系统可能更为合适。

后续步骤

我们处理模型请求、有序模型回退和提供商路由。你的应用程序处理工具执行及其相关限制。

如需了解完整的请求和响应结构,请参阅工具调用指南。有关本指南中使用的 SDK 请求字段,请参阅 TypeScript SDK 概述。若要使用第二个模型对循环的输出进行评分,请参阅《LLM-as-a-Judge:自动评估 AI 智能体输出》。

常见问题解答

如何决定智能体循环何时停止?

当模型不再返回任何工具调用、重复执行了被禁止的调用,或出现其他特定条件时,智能体循环应停止。

原文链接:https://openrouter.ai/blog/tutorials/build-tool-calling-agent-loop/
来源:OpenRouter
以上内容由 AI 自动翻译,仅供参考。
← 返回简报