前言 :封闭告一段落,开始系统复盘:一个生产 Agent 到底怎么跑起来。踩坑、经验和落地,最主要的是用户认可。尽量用几篇文章讲清,这篇文章是系列第4篇:模型说出「要调哪个工具」之后,真正决定稳不稳的,是参数对不对、有没有权限、同一件事会不会执行两遍、结果怎么安全写回对话------这一层叫 Tool Runtime(工具执行层)。
用户说:"查一下生产环境 payment-api 最近半小时的错误日志。"
上一篇把循环怎么转、怎么停讲清楚了。这篇盯住:模型已经说出要调某个工具之后,你的代码怎么把这次工具调用做稳。
接上 Function Calling(模型按约定格式说出调哪个工具、带什么参数),还不等于有了工具系统。模型输出一个工具名和一段 JSON 参数之后,麻烦才开始:
- 参数合不合法;
- 有没有权限;
- 能不能一起跑;
- 超时了能不能重试;
- 结果怎么写回下一轮对话。
很多线上事故不是模型不会选工具,而是「真去跑工具」那一层缺关键步骤:
- 模型给的参数几乎不检查,就直接拿去调业务接口;
- 重启、创建这类写操作一超时或失败,不管对面成没成又立刻再调一次(容易执行两遍);
- 把报错堆栈原文写回 Prompt;
- 把用户原文里的「我是管理员」当成已鉴权。
继续用案例 OpsPilot(企业运维助手,虚构案例),技术栈仍是 TypeScript + Zod。下面按一次调用的顺序写:注册、约定参数、校验、鉴权、并行还是串行、超时重试、结果写进对话。
先分清两件事(重要)
模型说出要调哪个工具 ,和代码真去跑这个工具,是两回事:
text
Function Calling / Tool Use
→ 模型决定「要不要调、调哪个、参数是什么」(还没真执行)
Tool Runtime(工具执行层)
→ 应用决定「允不允许调、怎么调、失败怎么办、结果怎么写回对话」
模型负责说出要调哪个工具,执行层负责把关。这个服务是不是当前租户的、这个用户有没有权限,都要你自己验。LangChain 的 bind_tools / @tool 主要解决「怎么让模型看见工具」;鉴权、防重复执行、写操作串行、结果去掉敏感信息,仍要你自己的 Tool Runtime 做。
1、每次工具调用,建议按照下面固定几步,事后能看出卡在哪:
text
按名字找到工具
→ 解析模型参数
→ 先按表校验,再做业务校验
→ 用登录态鉴权
→ 高风险?先让人确认
→ 执行(超时 / 取消 / 沙箱)
→ 整理结果(结构化 + 去掉敏感信息)
→ 写进对话 / 推前端 / 记审计
2、对应一条调用记录:
ts
// 一次工具调用的落库/日志记录,方便事后排查卡在哪一步
type ToolInvocation = {
invocationId: string // 你们自己生成的记录 id,跟数据库行对应
callId: string // 模型那次「要调工具」自带的 id;写回对话时必须原样带回,少一条会 400
toolName: string // 调的是哪个工具,如 query_service_logs
status: // 当前走到哪:校验过了?被拒了?还在跑?
| 'validated' // 参数校验通过,还没真正执行
| 'denied' // 没权限,被拦住了
| 'running' // 正在调下游
| 'succeeded' // 成功
| 'failed' // 失败(业务错、下游错等)
| 'timed_out' // 超时
| 'canceled' // 用户点了停止
args: unknown // 校验通过后的参数(可以落库)
result?: unknown // 成功时的结果;太大就截断或只存摘要
errorCode?: string // 失败时的错误码,如 NOT_FOUND,方便分类、别只存一句话
startedAt?: string // 开始时间
endedAt?: string // 结束时间
durationMs?: number // 耗了多少毫秒
}
注册与参数约定
工具集中注册,别散落 if
把工具登记在一张表里,别在 Agent 循环里到处写 if (name === 'xxx')。
ts
import { z } from 'zod'
// 跑工具时带上的上下文:身份从登录态来,不从模型参数里读
type ToolContext = {
auth: { userId: string; tenantId: string; roles: string[] } // 当前登录用户/租户
conversationId: string // 哪个会话
requestId: string // 哪一次请求,写审计、做幂等键时用
abortSignal: AbortSignal // 用户点「停止」时,靠它打断正在跑的工具
}
// 一件工具:给模型看的说明 + 给程序用的规矩 + 真正执行函数
type ToolDefinition<TArgs, TResult> = {
name: string // 工具名,全局唯一,模型靠这个点名
description: string // 写给模型看:什么时候该用、什么时候别用
inputSchema: z.ZodType<TArgs> // 参数长什么样(Zod);执行前要 parse 一遍
outputSchema?: z.ZodType<TResult> // 可选:返回值长什么样,方便自测
sideEffect: 'read' | 'write' // read=只查;write=会改数据(影响能不能一起跑、要不要审批)
parallelizable: boolean // true=可以和别的只读工具同时跑;false=必须单独跑
timeoutMs: number // 这一次最多等多久(毫秒)
retry: {
maxAttempts: number // 最多试几次(含第一次)
retryableErrors: string[] // 只有列在这里的错误码才允许再试,别见错就重试
}
permission: string // 需要什么权限,如 'logs:read'
requiresApproval?: boolean // true=先弹确认给用户点,再执行
execute: (args: TArgs, ctx: ToolContext) => Promise<TResult> // 真正调业务 API
}
const registry = new Map<string, ToolDefinition<any, any>>() // 名字 → 工具定义
function registerTool<TArgs, TResult>(tool: ToolDefinition<TArgs, TResult>) {
if (registry.has(tool.name)) {
// 同名再注册会悄悄盖掉旧实现,线上很难查;直接抛错更安全
throw new Error(`duplicate tool: ${tool.name}`)
}
registry.set(tool.name, tool)
}
注册时,顺便生成给模型看的 tools 列表。description 写清楚何时用、何时不用,比在主 Prompt 里再抄一遍更管用(强烈建议):
ts
// 示例:注册一个「查服务日志」的只读工具
registerTool({
name: 'query_service_logs',
description:
'查询指定服务在某环境、某时间窗内的日志。仅用于实时排障,不用于解释"日志功能是什么"。',
inputSchema: z.object({
serviceId: z.string().min(1), // 服务 id,不能空
environment: z.enum(['development', 'testing', 'production']), // 只能三选一
timeRange: z.enum(['15m', '30m', '1h', '6h']).default('30m'), // 默认最近 30 分钟
level: z.enum(['error', 'warn', 'info']).optional() // 可选:按级别过滤
}),
sideEffect: 'read', // 只查,不改数据
parallelizable: true, // 可以和其他只读工具一起跑
timeoutMs: 8_000, // 建议起点 8 秒,按你们下游接口自测再调
retry: {
maxAttempts: 2, // 算上第一次,最多 2 次
retryableErrors: ['TIMEOUT', 'DEPENDENCY_UNAVAILABLE', 'RATE_LIMITED'] // 只有超时/下游不可用/被限流才重试
},
permission: 'logs:read', // 没这个权限就别执行
async execute(args, ctx) {
// 租户只信登录上下文;就算模型参数里塞了 tenantId,也以 ctx 为准
return logService.query({ ...args, tenantId: ctx.auth.tenantId })
}
})
为什么呢?因为工具一多,把几百个工具的说明每次都放进 Prompt------又贵,模型也不容易选对。常见做法是两档:
- 每轮都带上的:查日志、查资源这类天天用的,固定给模型看十几个就够(具体个数按场景自测)。
- 偶尔才用的:比如冷门的导出报表、某条历史排障脚本。平时不放进 Prompt;真要用时,先用一个搜工具的小步骤按名字/描述找到它,再把这份工具的完整说明临时加进去,让模型这一轮能调。
参数怎么写、怎么验
参数表怎么写更稳:
- 能用枚举就别用随便写的字符串(环境、级别、操作类型);
- 必填就写清必填,可选就真的可选;
- 限制长度和格式(UUID、时间窗、最大条数);
- description 写业务约束,别只写「这是个 String」。
Zod(或 JSON Schema)先验「字段类型、必填、枚举合不合法」;通过之后,还要再验一层业务:格式对了,不等于这个服务属于当前租户。
ts
// 格式校验通过还不够:还要确认「这个服务是不是当前租户的」
function validateBusiness(
tool: ToolDefinition<any, any>,
args: unknown,
ctx: ToolContext
) {
const parsed = tool.inputSchema.parse(args) // 先按 Zod 表把字段验一遍
if ('serviceId' in parsed) {
const ok = serviceCatalog.existsInTenant(
parsed.serviceId,
ctx.auth.tenantId // 租户只看登录态,不看模型参数里的 tenantId
)
if (!ok) {
// 一律说「找不到」,别区分「不存在」和「在别人租户」------否则等于告诉别人对面有没有这个 id
throw Object.assign(new Error('service not found'), {
code: 'NOT_FOUND'
})
}
}
return parsed // 校验后的参数,后面鉴权、执行都用这份
}
工具跑完返回什么,也建议约定好字段(成功/失败、错误码、短摘要等),别只丢一大段自然语言:
ts
// 成功或失败都用同一种包装,方便写回对话
type ToolResultEnvelope =
| {
ok: true
data: unknown // 完整结果(可进对象存储)
summary: string // 短摘要,真正写进对话给模型看的是这个
artifactRef?: string // 结果太大时,只给一个引用 id,不要整段放进 Prompt
}
| {
ok: false // 失败也要走同一条写回路径,call id 不能丢
errorCode: string // 如 TIMEOUT,方便判断能不能重试
message: string // 给人/模型看的说明;不要堆栈、不要密钥
retryable: boolean // 上层要不要再试一次
}
这样模型更好判断下一步;你也好拦住「工具已经失败了,回答却说成功了」。
权限:谁有权执行,不信模型说的身份
鉴权只看用户登录态。不要信模型参数里的 role,也不要信用户原文里的「我是管理员」。
ts
// 鉴权结果:要么放行,要么结构化拒绝(别 throw,方便写回对话)
type AuthResult =
| { ok: true }
| {
ok: false
errorCode: 'PERMISSION_DENIED'
message: string
retryable: false // 没权限再试也没用,永远 false
}
// 问权限系统:这个登录用户,能不能对这个资源做这件事
async function authorize(
tool: ToolDefinition<any, any>,
args: unknown,
ctx: ToolContext
): Promise<AuthResult> {
const allowed = await iam.check({
userId: ctx.auth.userId, // 只信登录态里的用户
tenantId: ctx.auth.tenantId, // 只信登录态里的租户
permission: tool.permission, // 工具声明需要的权限
resource: extractResource(args) // 从参数里抽出要动的资源,如 serviceId
})
if (!allowed) {
return {
ok: false,
errorCode: 'PERMISSION_DENIED',
message: '当前账号无权执行该操作',
retryable: false
}
}
return { ok: true }
}
校验和鉴权之外,建议再挡三层(很多团队会省,省了容易出事):
- 这轮会话里根本不给某些工具 :客服 Agent 的工具表里就别放
delete_cluster。模型就算点名了,Runtime 一看「没有这工具」直接拒,不是「有工具但没权限」。 - 危险动作先让人点确认 :重启、扣费、改权限,先问人(HITL,人在回路)。人点了之后,再重新查一遍权限 再真跑。LangGraph 的
interrupt暂停再继续,就是这类。 - 就算批准了,也要限制执行边界:批准「跑测试」≠ 能读本机家目录、随便上网。权限管「这类事能不能做」;沙箱管「做的时候能碰哪些目录、网络」。
高风险写操作(删、改、扣费)一次只跑一个,并关掉并行 tool calls,避免查询和删除混在同一批里。
并行、重试与幂等
只读可并行,写操作串行
模型一轮可能点名多个工具;能不能一起跑,由你决定。LangChain / LangGraph 的工具节点可以并行,但不替你区分只读 / 写操作。
- 互不依赖的只读(查监控 + 查日志):可并行。
- 有先后依赖(先拿到服务 id,再查日志):必须串行。
- 写操作 / 要审批的 :串行,通常关掉
parallel_tool_calls。
ts
async function runToolCalls(
calls: Array<{ callId: string; name: string; args: unknown }>,
ctx: ToolContext
) {
const tools = calls.map(call => {
const tool = registry.get(call.name)
if (!tool) {
throw Object.assign(new Error('unknown tool'), { code: 'UNKNOWN_TOOL' })
}
return { call, tool }
})
const hasWrite = tools.some(item => item.tool.sideEffect === 'write')
const allParallelizable = tools.every(item => item.tool.parallelizable)
// 有写操作或不可并行 → 排队;否则并行
if (hasWrite || !allParallelizable || tools.length === 1) {
const results = []
for (const item of tools) {
results.push(await invokeOne(item.call, item.tool, ctx))
}
return results
}
return Promise.all(tools.map(item => invokeOne(item.call, item.tool, ctx)))
}
固定的过滤、聚合、去重,可以用代码合并结果再写回对话,少让模型多跑几轮。写操作和要审批的,仍建议走直接 tool calling,权限留在你这边。
重试与幂等
失败后不要一律「再试三次」。先分清错误,再决定要不要重试;会改数据的操作还要防止重复执行。
哪种错能重试:
| 错误 | 重试? | 说明 |
|---|---|---|
| 参数错 / 没权限 / 找不到资源 | 否 | 再调也解决不了;写回对话,让模型改参数或换查询 |
| 超时 / 限流 / 下游暂时不可用 | 有限次 | 间隔后再试,不要立刻连续请求 |
| 写操作超时、成败不明 | 否(先查状态) | 可能已经成功;确认未成功后再带同一幂等键重试 |
ts
// 按错误类型决定能不能重试
type ErrorClass =
| 'invalid_arguments'
| 'permission_denied'
| 'not_found'
| 'timeout'
| 'rate_limited'
| 'dependency_unavailable'
| 'side_effect_unknown' // 写操作超时、成败未知
| 'execution_failed'
function classifyError(err: unknown): { code: ErrorClass; retryable: boolean } {
// 按你们自己的错误码映射即可
...
}
幂等键: 给会改数据的那次操作一个固定编号。下游收到相同编号两次,只执行一次。
举例:重启 API 等了 30 秒没返回,对面可能已经重启成功,只是响应丢了。再调一次就可能重启两次。做法:
- 第一次就把幂等键传到真正执行的 API(只记在 Agent 内存里不够)。
- 超时后先查实例状态;确认没做成,再用同一个幂等键重试。
只读一般不需要幂等键。LangChain 一类的 retry 只负责「再调一次」;幂等和「超时先查状态」要自己接到下游。
执行一个工具时(校验 → 鉴权 → 可选审批 → 执行):
ts
async function invokeOne(
call: { callId: string; name: string; args: unknown },
tool: ToolDefinition<any, any>,
ctx: ToolContext
) {
let attempt = 0
const idempotencyKey =
tool.sideEffect === 'write'
? `${ctx.requestId}:${call.callId}`
: undefined
while (true) {
attempt += 1
const signal = AbortSignal.any([
ctx.abortSignal,
AbortSignal.timeout(tool.timeoutMs)
])
try {
const args = validateBusiness(tool, call.args, ctx)
const auth = await authorize(tool, args, ctx)
if (!auth.ok) {
return {
callId: call.callId,
ok: false as const,
errorCode: auth.errorCode,
message: auth.message,
retryable: auth.retryable
}
}
if (tool.requiresApproval) {
await waitForApproval({ tool, args, ctx })
const again = await authorize(tool, args, ctx) // 确认后再验一次权限
if (!again.ok) {
return {
callId: call.callId,
ok: false as const,
errorCode: again.errorCode,
message: again.message,
retryable: again.retryable
}
}
}
const data = await tool.execute(args, {
...ctx,
abortSignal: signal,
idempotencyKey
} as any)
return {
callId: call.callId,
ok: true as const,
summary: summarizeForModel(data),
data
}
} catch (err) {
const { code, retryable } = classifyError(err)
const canRetry =
retryable &&
attempt < tool.retry.maxAttempts &&
tool.retry.retryableErrors.includes(code)
if (!canRetry) {
return {
callId: call.callId,
ok: false as const,
errorCode: code,
message: toSafeMessage(err),
retryable
}
}
await sleep(backoff(attempt))
}
}
}
失败时怎么跟用户说:
- 主查询失败:说明没查到或只查到一部分;不要编造数据。
- 次要工具失败:仍可回答,但要说清「哪一块没查到」。
- 全部失败:根据结构化错误说明办不了;不要说「已处理完成」。
结果写进对话,以及统一入口
工具跑完,要把结果写进对话,再交给模型看------不是把终端输出原样贴回去。LangChain 里常见是 ToolMessage(带上原来的 tool_call_id);名字因框架而异,要求一样:每个 id 都要对上一条结果,成功失败都算。
ts
// 把执行结果收成一条 role=tool 的消息,写回对话给下一轮模型看
function toModelToolMessage(result: {
callId: string
ok: boolean
summary?: string
data?: unknown
errorCode?: string
message?: string
}) {
if (!result.ok) {
return {
role: 'tool' as const,
tool_call_id: result.callId, // 必须等于模型那次 tool_call 的 id,对不上接口会 400
content: JSON.stringify({
ok: false,
errorCode: result.errorCode,
message: result.message
})
}
}
return {
role: 'tool' as const,
tool_call_id: result.callId,
content: JSON.stringify({
ok: true,
summary: result.summary,
data: trimForModel(result.data), // 裁短、去敏感字段,不要把几万字日志整段放进上下文
artifactRef: maybeStore(result.data) // 完整大结果存别处,这里只留一个引用 id
})
}
}
注意这几条:
- 保留
call_id/tool_call_id,和模型那次「要调工具」的 id 对齐。 - 控制体积:日志限制条数、只留该留的字段、超长截断;完整结果进对象存储。
- 去掉敏感信息:Token、手机号、内部 URL、堆栈,默认不进模型上下文,也不进 SSE(服务端推给前端的事件)。
- 失败也要有固定字段 :
ok/errorCode/retryable比一段散文好用,模型和下一步代码都认得。 - 前端进度 :用
tool_started/tool_finished这类业务事件推,不要让 UI 靠解析模型输出文本来猜。
工具失败后,最终回答那一层必须能看到结构化失败。否则最常见的幻觉就是:工具超时了,回答却写「已重启成功」。
执行层做成一个统一入口,主循环只往这里丢:
ts
// 主循环只把模型输出的 tool_calls 交给这里:解析 → 未知工具也写失败 → 执行 → 审计 → 全部变成 tool 消息
async function handleModelToolCalls(
toolCalls: Array<{
id: string
name: string
arguments: string
}>,
ctx: ToolContext
) {
// 模型给的 arguments 常是 JSON 字符串,先解析成对象
const parsedCalls = toolCalls.map(item => ({
callId: item.id,
name: item.name,
args: JSON.parse(item.arguments)
}))
const known: typeof parsedCalls = []
const earlyFailures = []
// 每个 call id 都要有一条结果。名字不认识也不能整批丢掉,否则下一轮对不齐
for (const call of parsedCalls) {
if (!registry.has(call.name)) {
earlyFailures.push(
toModelToolMessage({
callId: call.callId,
ok: false,
errorCode: 'UNKNOWN_TOOL',
message: `未注册的工具: ${call.name}`
})
)
} else {
known.push(call)
}
}
const results = known.length ? await runToolCalls(known, ctx) : []
// 记一笔审计:谁、哪次请求、调了啥、成没成(排障和合规都靠这个)
for (const result of results) {
await audit.write({
requestId: ctx.requestId,
userId: ctx.auth.userId,
toolName: known.find(c => c.callId === result.callId)?.name,
ok: result.ok,
errorCode: result.ok ? undefined : result.errorCode
})
}
// 未知工具的失败 + 真正执行的结果,一起交回主循环写进对话
return [
...earlyFailures,
...results.map(toModelToolMessage)
]
}
Agent 主循环只做:
text
模型输出
→ 若有 tool_calls:交给执行层
→ 把 tool 消息追加进对话
→ 再调模型
→ 直到最终回答或停下来问用户
别在主循环里写具体调哪个业务 API。工具一多,那是最先烂掉的地方。
怎么测、后面再加什么
先把这些场景盖住:
- 缺字段 / 多字段 / 枚举写错
- 跨租户的
serviceId - 没权限的用户去调写工具
- 只读是否真并行、写操作是否被强制串行
- 超时后:读操作可重试;写操作先确认成没成,再决定要不要再调
- 同一个幂等键重复调用,不会产生两次副作用
- 失败结果写进对话后,最终答案不会宣称成功
- SSE / 日志里看不到敏感原文
- 未知工具名返回结构化错误,且每个 call id 都有结果
第一阶段把上面跑通就够用。后面按需加:
- 工具版本怎么兼容(旧会话不因新参数挂掉)
- 按角色裁剪可见工具列表
- 只读结果缓存(键里要带租户和权限)
- 用代码合并多次只读结果,少烧几轮 Token
- 更细的沙箱(文件、出网、凭证)
小结
就这三条:
- 权限看登录用户,不看模型怎么说。 用户自称「我是管理员」、模型参数里带了
role,都不能当真;能不能调工具,只认登录态里的身份。 - 会改数据的操作,要带固定编号(幂等键),并且分清哪种错能重试。 参数错、没权限 → 不要再试;超时不知道成没成 → 先查状态,不要盲目再调一次,否则可能执行两次。
- 写回给模型的,是裁短、去掉敏感信息后的结果。 不要把报错堆栈、Token、手机号整段放进 Prompt;失败也要写成固定字段,不要让模型在工具已经失败时还说「已成功」。
Function Calling 解决「模型怎么调用工具」;能不能上生产,看执行层:注册、校验、鉴权、失败分类、安全写回对话。
下一篇继续:《Agent 多轮对话:SSE 与 HITL 中断恢复》。
希望这篇文章对你有所帮助、有所借鉴。