用 C# 实现 Agent 工具调用闭环
文章使用 Anthropic 官方 SDK,讲解工具定义、调用分发、结果回传及失败处理,串起完整的工具调用循环。作者称代码已在 Anthropic 13.2.0 和 PostgreSQL 18 环境编译运行。
AI 深度解读
模型能回答订单状态,不代表它能查询订单。Function Calling 补上的是这段执行链路:模型提出结构化调用,应用执行真实代码,再把结果交回模型。这篇文章用 C# 和 Anthropic SDK 拆解了完整循环,重点在消息如何衔接、失败如何恢复,以及写操作怎样避免重复执行。
对前端和全栈开发者,它的价值不只在 C# 示例。理解这套协议后,你更容易判断一个 Agent 框架替你处理了什么,又有哪些数据库权限、超时和审批问题,仍然得由业务代码负责。
模型负责提出调用,应用负责真正执行
在 Messages API 中,工具调用属于对话消息的一部分。模型返回 stop_reason: "tool_use",同时给出一个或多个 tool_use 内容块,每块包含调用 id、工具名和 JSON 参数。
应用收到后执行数据库查询或内部 API,再通过下一条 user 消息提交 tool_result。模型看到结果,可能继续调用工具,也可能用 stop_reason: "end_turn" 结束。
这里的 user 消息是协议中的消息角色,不代表用户又手动输入了一次。
相较于让模型生成一段文字,再用正则猜测意图,这套机制有三个明确优势:参数有 Schema 约束,错误可以作为结果返回,同一轮还能提出多个工具调用。但模型没有直接获得执行权限,真正能做什么,仍由应用暴露的工具决定。
作者使用官方 Anthropic SDK,称所有代码都在 Anthropic 13.2.0 和 PostgreSQL 18 上编译运行过。安装命令是 dotnet add package Anthropic。这里有个依赖陷阱:NuGet 上的 Anthropic 从版本 10 起才是官方 Claude SDK;3.x 及以下属于社区 tryAGI SDK,后者现在使用 tryAGI.Anthropic,两套 API 完全不同。
工具定义包含名称、描述和输入 Schema。示例工具 get_order_details 要求提供字符串 order_id,格式示例为 ORD-20261234;可选布尔参数 include_line_items 决定是否返回明细。描述还要明确告诉模型,查询订单状态、送达日期和商品内容时应调用它。
工具描述是模型选择工具的依据,不能只当成人类阅读的接口注释。
SDK 用 ToolUnion 容纳自定义工具和内置工具,Tool 可以隐式转换,因此 [getOrderTool] 能直接传给 MessageCreateParams.Tools。严格格式也可以通过 InputExamples 提供真实参数示例。
循环最容易错的地方,是调用与结果没有完整配对
原文循环通过 client.Messages.Create 请求模型,配置为 Model = "claude-sonnet-5-5"、MaxTokens = 4096,最多运行 10 轮。这里的模型名是原文配置;文章没有给出它的可用范围说明。
模型响应后,应用首先要把完整 assistant 消息写回历史,包括其中的 tool_use。随后执行所有调用,并把全部结果放进同一条 user 消息,每个结果都用 ToolUseID 对应此前的调用。
下面这段代码解决并行执行、单工具超时和结果集中回传的问题:
var resultTasks = toolUseBlocks.Select(async block =>
{
try
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(30)); // 每个工具的超时限制
var result = await dispatcher(block.Name, block.Input, cts.Token);
return new ToolResultBlockParam
{
ToolUseID = block.ID,
Content = JsonSerializer.Serialize(result)
};
}
catch (Exception ex)
{
// 返回结构化错误,让模型有机会恢复
return new ToolResultBlockParam
{
ToolUseID = block.ID,
Content = JsonSerializer.Serialize(new { error = ex.Message }),
IsError = true
};
}
});
var toolResults = await Task.WhenAll(resultTasks);
// 所有结果放入同一条 user 消息,每个调用对应一个结果块
messages.Add(new UserMessageParam
{
Content = toolResults.Select(r => (ContentBlockParam)r).ToList()
});
重点看 ToolUseID = block.ID 和最后的 messages.Add:只处理第一个调用,或者把结果拆成多条消息,都会破坏原文要求的配对关系。
响应中的 ContentBlock 不能直接作为请求参数使用。作者通过 ToParam 把 .Value 中的 TextBlock、ToolUseBlock 转成对应参数类型。开启 extended thinking 后,还必须保留 thinking blocks,示例转换函数需要补充分支。
作者对这部分做了一个明确测试:本地服务按 Messages API 格式先返回两个调用,一个查询订单,一个调用不存在的工具,然后返回 end_turn。他们检查了第二次请求,确认其中保留了两个调用,并用一条 user 消息回传两个结果;未知工具标记为 "is_error": true,没有让循环崩溃。
这验证了消息拼接和错误回传路径,但没有证明真实模型会稳定选择正确工具。
Schema 管参数形状,业务代码仍要守住边界
工具输入仍是不可信数据。原文 dispatcher 从 IReadOnlyDictionary<string, JsonElement> 读取参数,用 TryGetValue 处理缺失字段,再检查 ValueKind。如果直接调用 GetBoolean(),模型一旦传来字符串,就可能抛异常。
订单号通过 ^ORD-\d{8}$ 校验后,才进入 EF Core 查询。查询使用 AsNoTracking(),只有需要明细时才增加 Include。不能写成 Include(o => includeLineItems ? o.LineItems : null):它虽然能编译,但 EF Core 要求明确的导航属性路径,运行时会抛出 InvalidOperationException。
返回值也应保持克制。示例只返回订单号、状态、按 yyyy-MM-dd 格式输出的预计送达日期,以及按需提供的明细,避免把实体关系图整包送回模型。
错误同样需要设计。订单不存在是模型可以解释的业务结果,堆栈信息通常没有帮助。原文用 JsonSerializer.Serialize 序列化异常,避免引号破坏 JSON;但直接返回 ex.Message 是否合适,还要看其中有没有内部信息。
写工具则多了一层要求:超时不等于操作没发生。cancel_order 第一次可能已经成功,只是结果没送回来。模型重试时,处理器必须通过幂等键或检查后执行的逻辑,保证重复调用不会产生重复影响。
生产环境还需要只读凭证、通过 DI 注入服务令牌、记录工具名和耗时等结构化日志。令牌不能进入 Schema 或对话。工具返回的 50 KB JSON 会占用后续轮次的上下文,因此可以拆成摘要工具和详情工具,让模型按需获取。
涉及发邮件、扣款、取消订单或修改生产数据,作者建议先返回 {"status": "pending_approval", "approval_id": "..."},确认后再执行。
我的判断
我认同先看懂消息循环,再引入 Semantic Kernel 或 Microsoft.Extensions.AI。它能帮助开发者定位问题:到底是模型没选工具、参数不对,还是结果回传出了错。不过,作者没有给出这些方案的对照数据,不能据此认定直接使用 SDK 更可靠或更省成本。
示例也还有几处工程前提。CancelAfter 发出的是取消信号,下游代码必须响应 CancellationToken,才可能及时退出;catch (Exception) 会把取消也当成普通工具错误,真实项目要区分用户取消、工具超时和业务失败。
并行执行也不宜直接照搬到全部工具。独立查询适合并发,存在先后依赖或修改同一份数据的写操作,需要另外安排执行顺序。原文没有展开这部分。
另一个缺口是用户级授权。只读数据库账号限制了写入能力,却不能保证某位用户有权查询某张订单。示例查询条件只有订单号,实际服务还需要结合登录身份或租户范围过滤。
因此,这篇更适合作为协议与处理器设计的起点。作者提供了编译运行声明和一次本地协议测试,尚不足以支撑整套方案已经完成生产验证。
能拿来做什么
- 给现有 AI 助手增加工具时,可以先选一个只读查询,写清工具描述、必填参数和业务错误,再检查调用历史与结果是否完整配对。
- 在 Node 或其他技术栈实现循环时,可以保留原文的轮数上限、单工具超时和集中回传规则,并补测一次返回多个调用、包含未知工具的情况。
- 给订单等业务工具接入数据库前,应把身份校验、参数校验和字段裁剪放进处理器,让模型只能获取当前用户有权查看的必要数据。
- 增加写操作前,应先确定幂等策略和审批后的执行入口,明确超时重试会发生什么,再让模型调用它。