ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

拆解 Agent Framework 的 RunAsync:一次工具调用的完整生命周期

拆解 Agent Framework 的 RunAsync:一次工具调用的完整生命周期 如果你在项目里接触过 Agent Framework大概率对RunAsync这个入口不陌生。它是 Agent 从接收消息到产出回复的“一次执行周期”也是理解 Agent 生命周期最重要的方法之一。这系列第二篇我用一个差旅助手作为例子完整拆解一次RunAsync到底是怎么跑完的消息进来之后去了哪、模型怎么决策、工具怎么被调用、什么时候停止、结果怎么返回。适合刚看完 Agent Framework 基础概念、正准备上手写真实 Agent 的开发者也适合想搞清楚“框架到底帮我做了什么”的读者。先说结论RunAsync并不是简单地把用户消息丢给大模型然后等回复它内部包含上下文恢复、指令组装、模型调用、工具执行、终止判断、状态持久化等多个阶段。只有把这些阶段拆开看明白你才能在遇到“Agent 不听话”“工具反复调用”“上下文越聊越乱”这类问题时快速定位到底哪一环出了问题。1. 从 RunAsync 的入口开始一次调用的全貌1.1 RunAsync 是什么RunAsync 本质上是 Agent Framework 对外暴露的异步执行入口。你调用它传入用户的输入消息框架负责把这条消息变成一组模型调用和工具执行序列最后把结果返回给你。它之所以叫“Async”是因为整个过程不阻塞调用线程内部会通过异步迭代、流式回调等方式把模型输出、工具执行进度逐步推送出来。很多刚开始接触 Agent Framework 的同学会把 RunAsync 理解成“发一条消息给 ChatGPT 然后拿到回复”。这个理解方向没错但漏掉了最关键的部分Agent 不是单次模型调用它是一个循环。RunAsync 内部会反复执行“模型生成 → 如果有工具调用就执行工具 → 把工具结果放回上下文 → 再次调用模型”这个循环直到模型不再产生工具调用或者满足预设的终止条件为止。所以一次 RunAsync 可能包含多次模型调用。例如差旅助手处理“周五从上海去北京出差帮我安排一下行程”时可能先调用航班查询工具再调用天气查询工具最后还要调用预算计算工具每次工具调用后模型都要重新“想”一次。这些工具调用和模型思考都发生在同一次 RunAsync 里。这也就引出一个实际问题如果你把 RunAsync 当成“单轮对话”那你对 Agent 的所有预期都会错位。你需要把它当成“一个任务执行器”而不是“一个聊天接口”。1.2 差旅助手这个例子为什么合适选差旅助手做例子是因为它的工具调用路径非常典型几乎覆盖了 RunAsync 的所有关键阶段。我先把这个例子的业务设定说清楚差旅助手是一个 Agent它能帮用户完成差旅安排相关的任务。它手里有四个工具查询航班输入出发地、目的地、日期返回航班列表航班号、时间、价格。查询天气输入城市、日期返回天气状况和温度。查询酒店输入城市、日期返回可预订酒店及价格。计算总价输入一组费用明细返回总额。用户输入“我周五从上海去北京出差帮我看看航班和天气再帮我订个酒店预算控制在3000以内。”这个需求看起来简单但模型如果直接回答它并不知道真实的航班、天气和酒店数据所以必须依次调用三个查询工具最后还要通过计算总价来确认预算是否满足。这个“多工具按顺序调用”的过程恰好可以把 RunAsync 内部的模型调用循环完整地暴露出来。为什么这个例子“合适”因为它不涉及多 Agent 协同、群聊这类高级特性聚焦在单个 Agent 的一次异步执行上。这样你能看清楚最本质的执行链路后面再去理解多 Agent 编排时会轻松很多。2. 一次 RunAsync 的完整生命周期拆解2.1 第一步消息从哪来上下文怎么恢复RunAsync 的第一步不是“调用模型”而是“准备对话上下文”。Agent Framework 会从你创建的 Agent 实例和线程Thread中恢复历史消息。你可以把 Agent 理解成一个“有系统指令、有工具能力的角色”把 Thread 理解成“这个角色和用户之间的连续会话记录”。差旅助手就是 Agent它和用户之间所有历史消息都放在 Thread 里。用户新发来一句话RunAsync 会先把这句话追加到 Thread 的消息列表中然后把这个列表整体交给模型。这个过程有一个容易踩的坑很多人以为每次 RunAsync 都是独立无状态的其实框架默认会带上历史消息。如果用户前面说“我从上海出发”后面说“帮我看看北京航班怎么样”模型因为能看到历史才知道出发地是上海。但代价是历史越长每次调用的 token 消耗越大。所以一次 RunAsync 的开始阶段实际做的是从 Thread 中读取当前会话的历史消息。把用户的新输入封装成 ChatMessage 追加到上下文中。把 Agent 的系统指令、工具定义和会话历史一起组装成模型请求。在这一步框架还会做一些辅助工作比如给消息生成 ID、记录时间戳有的实现里还会对消息内容做序列化。整个过程对开发者是透明的如果你自己调试过 API 请求体你会发现发出去的 messages 数组里历史消息、工具定义、系统提示词都在。2.2 第二步指令系统和工具声明的组装RunAsync 内部会把你创建 Agent 时配置的 SystemPrompt、工具定义等全部组装进请求里。这步看似简单却是决定 Agent“听不听话”的核心。差旅助手的系统指令大概长这样“你是差旅助手负责帮用户安排差旅行程。你必须使用工具获取实时信息只有工具返回结果后才能回答用户问题。预算不足时需要向用户说明并给出备选方案。”工具声明则更关键。在差旅助手例子里查询航班、查询天气、查询酒店、计算总价这4个工具会被转成模型能理解的结构化描述。Agent Framework 支持多种工具注册方式比如直接引用函数、加载 OpenAPI 文档、或者用预定义的 Tool 类型。这里我要特别强调一下工具声明里的描述信息为什么重要。以“查询航班”为例function_name: search_flights description: 查询指定日期从出发地到目的地的航班列表 parameters: departure: 出发地城市名 destination: 目的地城市名 date: 日期格式为 YYYY-MM-DD描述写得越准确模型就越可能用正确参数调用工具。如果你只写“航班查询”模型可能搞不清日期格式甚至不知道该把出发地和目的地放在哪个字段。另一个值得注意的细节是Agent Framework 会把工具声明和系统指令放在请求的不同位置但都会参与模型生成。你可以在调试日志里看到一条 RunAsync 请求实际上由三块内容构成系统指令、工具定义、会话历史。这三块组装完模型才会被调用。2.3 第三步模型调用与首轮决策组装完请求之后RunAsync 进入真正的模型调用阶段。这个阶段没什么神秘感就是把请求发到模型服务拿到第一轮输出。但这里有一个关键点模型的第一轮输出通常不是最终答案而是一个“决策结果”。决策结果有两种可能模型直接生成最终回答文本这说明模型认为不需要调用任何工具就能回答问题。模型生成一个或多个 ToolCall 指令说明模型决定调用工具来获取更多信息。差旅助手的场景里如果用户问“你是什么”模型可能直接回答“我是差旅助手”不需要工具。但如果用户问“周五上海到北京的航班”模型必须生成一个 ToolCall调用 search_flights 工具。在实际开发中你会在这一阶段明显感受到 Agent 与普通 Chat 接口的区别。普通 Chat 接口返回一条文本任务就结束了。而 Agent Framework 的 RunAsync 在拿到模型第一轮输出后会先检查里面有没有 ToolCall 字段。有工具调用就进入工具执行阶段没有工具调用才把文本输出作为最终结果。首轮决策的质量很大程度上取决于系统指令和工具描述是否清晰。差旅助手如果系统指令里没说“工具返回结果后才能回答”模型很可能在拿到工具结果之前就凭记忆编造一个航班信息导致虚构答案出现。2.4 第四步工具调用循环工具调用循环是 RunAsync 最核心的机制也是它和普通 API 调用最本质的区别。框架检测到模型返回了 ToolCall 之后会做三件事根据 ToolCall 里的工具名找到对应实现。解析出参数执行工具函数。把工具执行结果作为一条“工具消息”追加回会话上下文。然后框架会拿着包含工具结果的完整上下文再次调用模型。模型看到工具结果后可能再发起新的 ToolCall也可能就此生成最终回答。这个过程会反复循环直到模型不再发起新的工具调用。差旅助手的典型调用序列可能是这样的用户周五上海到北京查下航班和天气 模型调用 search_flights(departure上海, destination北京, date2025-01-10) 框架执行 search_flights返回航班列表 模型调用 search_weather(city北京, date2025-01-10) 框架执行 search_weather返回天气结果 模型调用 search_hotels(city北京, date2025-01-10) 框架执行 search_hotels返回酒店列表 模型根据以上工具结果生成最终回答整个序列里模型被调用了4次工具被执行了3次但对外部来说用户只发起了一次 RunAsync。这就是 Agent 和普通 Chat 接口体验上的最大差别。这里有一个常见的疑问为什么框架不一次性把所有工具结果都交给模型原因是模型并不知道你的工具具体会返回什么它只能根据用户需求“逐步探索”。这种逐步探索的机制也带来了一个副作用就是执行时间变长、token 消耗变多。后面我会专门聊怎么限制这个循环。3. 核心细节流式输出、终止条件与状态管理3.1 为什么要有终止条件前面说了 RunAsync 内部是一个“模型调用 → 工具执行 → 再调用模型”的循环。如果没有终止条件模型可能永远在调用工具永远不生成最终答案。这种问题在实际项目中很常见比如模型反复查询同一个航班或者查完一个城市又去查另一个城市始终不收敛。Agent Framework 提供了多种终止条件最常见的有三种最大迭代次数限制工具调用循环最多执行多少轮超过就强制停止。模型不再产生 ToolCall模型只返回文本不再要求调用工具。自定义终止条件开发者自己写逻辑比如判断工具结果是否满足用户需求然后主动中断循环。在差旅助手这个例子里最大迭代次数是非常有必要的。因为用户需求可能很模糊模型可能反复调用工具去“确认”各种信息。我见过一次调试中模型连续调了7次工具就为了确认某个城市的酒店价格最后还是给了个模棱两可的回答。加了最大迭代次数之后即使模型不收敛框架也会超时退出把已经收集到的信息整合成回复返回给用户。关于终止条件我看过不少实现很多人的做法是把“最多执行 N 轮”写死在代码里但更推荐的做法是把终止条件同时写进系统指令。比如差旅助手的系统指令里加上一句“如果一次查询就能获得足够信息不要重复查询同一个工具”能有效减少无效工具调用。3.2 流式回调与进度事件很多 Agent Framework 的 RunAsync 都有流式版本它和非流式的区别在于非流式会等所有循环结束返回一个完整结果流式会在执行过程中持续抛出事件比如模型每生成一个 token、每个工具开始执行、每个工具执行完毕。流式机制对差旅助手这种场景特别有用。你想一下用户等一个“查航班 查天气 查酒店 算预算”的完整流程可能要等几十秒。如果界面一直没反应用户会以为程序挂了。用流式回调前端可以实时显示“正在查询航班”“航班数据已返回”“正在查询天气”这样的进度状态。这里的实操建议是区分模型增量事件和工具生命周期事件。模型增量是 tokens 的局部输出用来渲染打字机效果工具生命周期是框架层事件用来驱动 UI 上的状态流转。如果混在一起会出现 UI 上一会儿显示“正在打字”一会儿显示“正在调用工具”观感很混乱。另外要注意流式事件里的数据往往不是完整 JSON而是分片到达的。你要在回调里自己维护缓冲区和事件顺序。Agent Framework 通常会对事件做序列化标记但你在业务代码里最好也用一个递增计数器记录事件到达顺序防止极端情况下乱序处理。3.3 状态保存RunAsync 之间的线程延续一次 RunAsync 结束之后会话并没有马上消失。Agent Framework 会把这一轮产生的所有消息包括用户消息、工具调用、工具结果、最终回答写回到 Thread 中。下一次用户再发消息新的 RunAsync 会基于这些历史继续。这个机制让 Agent 有了“记忆”但也带来了两个问题。第一个问题历史消息无限制增长。差旅助手如果被一个用户连续用了一个月Thread 里的历史消息可能有几千条。每次 RunAsync 都要把这几千条发给模型token 成本会越来越高响应速度会越来越慢。解决办法是在合适的时机做消息摘要或截断把早期的原始消息压缩成一段摘要文字再与最近的完整消息一起送入模型。第二个问题并发执行导致上下文错乱。如果一个 Thread 同时被两个 RunAsync 执行两边都在往同一个消息列表里追加内容最后 Thread 状态会变得不可预测。好的做法是一个 Thread 同一时间只允许一个 RunAsync 执行或者为每个执行周期创建独立的快照上下文。在差旅助手这个例子中我建议每个“行程计划”单独开一个 Thread不要把所有用户的差旅需求都塞进同一个会话里。这样 RunAsync 之间的状态保持会清晰很多也方便后续对单次行程做回溯和分析。4. 实操用差旅助手复现一次 RunAsync4.1 最小可运行示例代码下面我用一段简化了的 .NET 风格伪代码展示差旅助手的 RunAsync 全流程。真实项目里你还需要按具体版本的 API 调整但核心逻辑是一致的。// 1. 创建 Agent var agent new ChatAgent.Builder() .WithName(TravelAssistant) .WithInstructions( 你是差旅助手。必须使用工具获取实时数据工具返回结果后才可回答用户。 预算不足时应说明原因并给出现有方案。 ) .WithTool(search_flights_tool) .WithTool(search_weather_tool) .WithTool(search_hotels_tool) .WithTool(calculate_cost_tool) .Build(); // 2. 创建线程会话上下文 var thread new AgentThread(); // 3. 用户发送消息触发 RunAsync var userMessage new ChatMessage( role: user, content: 周五上海到北京出差查下航班、天气和酒店预算3000以内 ); await foreach (var update in agent.RunAsync( message: userMessage, thread: thread, cancellationToken: token)) { // 流式事件处理 switch (update.Type) { case UpdateType.StreamingDelta: RenderToken(update.Content); break; case UpdateType.ToolCall: ShowToolStatus($正在调用工具{update.ToolName}); break; case UpdateType.ToolResult: ShowToolStatus($工具返回{update.ToolName}); break; case UpdateType.Complete: ShowFinalResponse(update.Content); break; } }这段代码里有几个关键点。第一Agent 构建时集中声明了“角色”和“工具”后面 RunAsync 时会自动组装进模型请求不需要每次手动传。第二RunAsync 返回的是一个异步事件流你用await foreach来消费。每个事件代表执行链路中的一个阶段模型生成片段、工具开始、工具返回、整个流程完成。第三Thread 对象通过参数传入RunAsync 内部会修改它的状态。如果 Thread 是空的就是开启新会话如果 Thread 已经有历史消息RunAsync 会先恢复历史再追加新消息。4.2 手工推演一次调用光看代码不够直观我用手工推演的方式带你走一遍完整流程。这里假设框架的最大迭代次数设置为 6 次。步骤事件上下文变化1用户消息进入 RunAsync上下文新增用户消息周五上海到北京出差查航班、天气、酒店预算3000以内2模型第一次生成输出 ToolCallsearch_flights(departure上海, destination北京, date2025-01-10)3框架执行 search_flights上下文新增工具结果航班列表MU5101、CA1858等4模型第二次生成输出 ToolCallsearch_weather(city北京, date2025-01-10)5框架执行 search_weather上下文新增工具结果北京晴-3℃到5℃6模型第三次生成输出 ToolCallsearch_hotels(city北京, date2025-01-10)7框架执行 search_hotels上下文新增工具结果酒店列表汉庭、如家、全季8模型第四次生成输出 ToolCallcalculate_cost(flightMU5101价格880, hotel全季价格520*2晚)9框架执行 calculate_cost上下文新增工具结果总价 1920 元10模型第五次生成不再输出 ToolCall输出最终回答11RunAsync 结束最终回答写入 Thread整个调用链结束推演完这 11 步你可以发现几个规律。一次 RunAsync 内部模型被调用了 5 次工具被调用了 4 次。这说明了为什么 Agent 类应用比普通的问答接口慢慢不是网络延迟而是多轮模型调用和工具执行累积出的事件开销。第 8、9 步值得单独说一句。模型把两个价格加起来的操作本身完全可以用人脑心算但 Agent 仍然选择了调用工具因为系统指令要求“工具返回结果后才能回答”而且调用工具可以避免算术错误。这其实是模型面对“精确性要求”时的一种合理选择。推演中隐藏了一个容易被忽略的问题如果某次模型调用输出的 ToolCall 里有一个工具名不存在框架会抛异常还是忽略答案取决于具体实现但我强烈建议你在工具执行阶段做一层 try-catch并在系统指令里告诉模型“如果工具调用失败请向用户说明”。差旅助手如果在查询航班时上游接口挂了正确行为是告诉用户“航班查询暂时不可用”而不是僵死在那里。4.3 工具注册时容易被忽略的参数映射工具注册是 RunAsync 链路里很多人第一次踩坑的地方。工具函数的参数名、类型、描述要和模型生成的 ToolCall 参数严格对应。差旅助手的 search_flights 工具如果你把参数写成parameters: from_city: 出发地 to_city: 目的地而你在函数实现里用的是departure和destination那模型生成 ToolCall 时可能会直接写 from_city 和 to_city。如果你的框架没有做参数映射函数调用就会因为缺少参数而失败。我建议你在注册工具时做一个严格的参数 schema 校验并加上默认值和容错逻辑。比如日期字段允许传“周五”这种相对表达时框架需要先把相对表达转成具体日期再传给工具函数。这一步转换逻辑通常放在工具内部做不要让模型替你做。另外工具返回值最好统一成结构化 JSON不要返回“一切正常”这种自然语言。因为模型需要从工具结果中提取信息来生成最终回答结构化 JSON 对它来说更友好也能减少幻觉。差旅助手的航班查询返回{ flights: [ {flight_no: MU5101, departure: 上海虹桥, arrival: 北京首都, departure_time: 08:00, arrival_time: 10:15, price: 880} ] }这种结构模型只需简单提取就能回答用户出错率会低很多。5. 常见问题与排查实录5.1 模型拿到工具结果后不收敛这是 RunAsync 实践中最常见的问题模型调用工具之后拿着工具结果又发起一个新的 ToolCall反复多次也不给最终答案。在差旅助手场景里典型表现是查完航班之后又查一遍航班或者查完航班去查天气查完天气又跑回来查酒店始终不组织最终回答。我排查这类问题时一般分三步。第一步确认系统指令里是否有明确的“收尾”指令。只写“帮助用户”是不够的要写清楚“当所需信息收集完成后直接给出完整回答不要继续调用工具”。第二步限制最大迭代次数。把运行上限设成实际需要工具数量的两倍左右差旅助手设 6 次足够。不要设成 100 次那不是给真实用户用的配置。第三步在工具结果里附上“这条结果的用途提示”。例如酒店查询工具返回时可以加一行“若用户预算允许可直接推荐本列表第一个酒店”引导模型尽早收尾。从我的实测经验看80% 的不收敛问题都能通过系统指令和 max_iteration 解决剩下 20% 是模型本身在复杂多目标场景下的规划能力不足需要拆成多个子任务分别执行。5.2 工具异常导致整轮 RunAsync 失败工具不是永远可靠的。差旅助手的天气服务可能超时航班接口可能限流酒店库存可能已满。默认情况下一次工具异常可能直接导致整个 RunAsync 失败用户只看到一句“系统错误”这体验非常糟糕。解决思路是在工具执行层统一做故障隔离。核心做法有两层。第一层是工具内部兜底。每个工具都返回一个固定结构的数据即使查询失败也返回{error: 上游服务超时, fallback: []}而不是抛异常。这样模型至少能知道“天气查不到”然后决定是继续尝试还是告知用户。第二层是框架层面降级。在工具执行阶段捕获异常把错误消息转成一条工具结果消息标记为失败。你可以在系统指令里补充工具返回 error 字段时说明该信息不可用请明确告知用户并基于现有信息给出建议。差旅助手如果查不到周五的航班但能查到周六的航班模型就应该回答“周五航班信息暂时不可用周六有航班是否调整行程”而不是卡在那里反复重试。5.3 并发问题多个用户同时触发 RunAsync当一个 Agent 被多个用户同时使用时你会遇到一类很隐蔽的问题用户 A 的 RunAsync 和用户 B 的 RunAsync 都往同一个 Thread 里追加消息最终模型看到的上下文是两段对话混在一起的。为什么会发生因为 Thread 是共享状态。如果你把一个 Thread 传给两个并发 RunAsync框架内部如果没有锁或者快照机制两边都会认为自己拿到了最新上下文然后各自写入最后相互覆盖。我在差旅助手里的做法是每个用户维护独立 Thread并且一个 Thread 同一时间只允许一个 RunAsync 执行。框架层面可以加一个简单的信号量或分布式锁Thread ID 作为锁的 key。用户如果连续快速发送两条消息后一条消息应该排队等待而不是并发执行。还有一种更彻底的做法不在 Thread 对象上做状态管理而是把每次 RunAsync 的输入输出显式保存到外部存储RunAsync 开始时重建上下文结束时持久化上下文。这样并发控制就变成了存储层的版本管理问题比在内存线程里做锁要健壮得多。5.4 排查实录一次 RunAsync 突然多出一次“幽灵工具调用”有一次我在调试差旅助手时发现用户只问了“北京天气怎么样”模型却在第一轮调用了 search_hotels。我一度以为是模型发疯了后来打开调试日志才发现Thread 里残留了上一轮对话中的“酒店”讨论模型在做上下文关联认为这次问天气前应该先确认酒店还在不在。这正是 RunAsync 恢复 Thread 历史消息特性带来的副作用。历史消息不仅给模型提供记忆也会影响模型对当前任务的判断。排查这类“幽灵工具调用”时第一步是检查 Thread 里的历史消息第二步是看系统指令是否足够清晰地把当前任务和历史任务隔离开。我后来在差旅助手的代码里加了一条处理规则每次用户发起新行程安排时先在一个新的 Thread 中运行不让上一次行程的上下文干扰当前任务。这样虽然少了“连续性”的便利但换来了更高的可控性。实际产品里你需要在这两者之间做取舍。6. 避坑总结与个人体会写到这里我对 RunAsync 的完整链路已经做了比较细致的拆解。最后再分享几个我实际使用 Agent Framework 时踩过坑之后攒下的经验。第一个经验调试 RunAsync 一定要开完整日志尤其是模型原始请求和响应。你只看最终结果是看不懂 Agent 行为的因为最终结果可能经过了 5 轮内部循环。只有看到每一轮模型返回了什么 ToolCall、工具结果是什么、下一轮模型如何引用这些结果才能定位问题。Agent Framework 通常提供回调或中间件机制来打印这些信息别省这一步。第二个经验系统指令不是写在开头就完事了。差旅助手这种多工具场景最好在系统指令末尾再加一段“输出规范”明确告诉模型最终回答必须列出航班时间、价格、天气情况和酒店价格并对预算给出结论。否则模型很容易漏掉某项信息你要花很长时间在提示词上调优。第三个经验也是我认为最重要的一点不要把 RunAsync 想成一个黑盒。当你把 Agent 当成黑盒遇到问题就只能“换提示词”或者“换模型”。但当你把 RunAsync 拆成上下文恢复、指令组装、模型决策、工具循环、终止判断这几个阶段你会发现自己能定位到具体环节然后针对性地修改。差旅助手这个例子做完之后我对 Agent 的理解改变了很多。以前我觉得 Agent 就是“API 提示词”但其实 RunAsync 的整个执行框架才是灵魂。模型负责“思考”框架负责“循环”工具负责“行动”三者缺一不可。后续如果你开始研究多 Agent 编排你会发现多个 Agent 之间的 RunAsync 协作会更加复杂但底层仍然离不开今天拆解的这些基础环节。最后再给一个能立刻用得上的小技巧在你自己的 Agent 代码里试着给每个 RunAsync 加一个 traceId把整个循环中的模型调用、工具调用都记录到同一个 traceId 下。排查问题的时候你只需要按 traceId 搜索就能看到一次 RunAsync 的完整生命周期比翻一堆零散日志高效得多。这也是我推荐所有 Agent 应用都尽早做好的可观测性建设。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表