Skip to content
Charles Shao
Go back

上线实战:轮内并发、超时、续跑与审计

–views

上一篇文章里,我们搭起的单次 run 调度器已经有了基础护栏与出口:模型推理、工具执行、结果回灌全链路打通,状态打转即终止,未确认的高危操作直接拒执,累计 Token 超限则兜底熔断。但这些状态机流转,只覆盖了单一任务目标从入循环到退出的理想路径。

一旦进入生产环境面对真实并发,这套单线程循环立刻就会崩盘。“西安订房”与”成都订房”同时涌入时,每个并发 run 都必须隔离维护各自的 messages 上下文,否则一连串问题会接连暴露:同一调度轮次内模型并发吐出三个 tool_calls,若”获取成都天气”的外部 API 阻塞,主循环既回灌不了结果也触不了超时,直接死锁;写操作 book_hotel 超时时,底层事务是否落盘尚不可知,盲目二次调用必酿成”双重预订”;用户完成「确认」后若系统错误地新开 Session,之前累积的记忆瞬间清零;事后排查时缺了全局唯一追踪标识,两笔并发订单的审计日志互相缠结,根本分不清哪一单触发了超时。显然,最基础的”套壳调用”循环扛不住这类工程挑战。

因此本文继续坚持”手搓”原则,剥离厚重的框架黑盒。在循环调度、记忆管理、执行计划、结果检查、护栏拦截这套底层架构不动的前提下,仍以”西安 10 月 1 日入住两晚,遇雨订套房(suite),否则订标准间(standard)“为测试基准,用纯底层代码编排硬核补齐几项关键工程能力:轮内多个 tool_calls 的并发调度与执行、单次工具调用的超时回灌策略、写操作超时下的防重与兜底、基于隔离上下文的 messages 独立维护,以及依赖 run_id 的按轮次落盘与状态机续跑。为聚焦架构逻辑,全程仍不发起真实模型推理请求。

为避免后续架构解析中的概念混淆,先将本文涉及的核心行话体系严格定义(回灌、配对、出口、终止、退避、打转、确认等术语与前文保持一致):

名字指什么
run一次任务目标从进入循环到触发出口的完整生命周期
run_id该次 run 全局唯一的追踪标识
轮内并发在同一调度轮次内,多个 tool_calls 被异步推入调度池同时执行
超时进程等待单次工具调用的墙上时钟(Wall Clock Time)耗尽
幂等键本次订房请求携带的、确保重复提交在下游系统只产生一次订单的唯一凭证
轨迹日志调度引擎在每轮当场打出的包含 run_id、工具名、调用参数及回灌结果的结构化流式日志
审计将特定 run_id 对应的系统内部原始 messages 快照落盘,用以事后离线重放与规则检查
续跑携带已有的 run_id 及其关联的 messages 快照,重新唤醒状态机并切入循环

核心架构理念:轮内 tool_calls 实施并发调度,但结果回灌必须严格对齐原始请求顺序以保证上下文一致性。对于只读工具,超时则透传错误回灌;对于写操作,超时后必须先查询状态再决策,并强制携带幂等键。每个调度轮次需将 messages 完整落盘,以支持用户确认后的断点续跑。

Table of contents

Open Table of contents

一、拆解轮内并发的调度流转

实际业务推进到「先查天气再搜酒店」这一节点时,得益于大模型的并发决策能力,它完全可能在同一调度轮次(Turn)内同时吐出三个工具调用(tool_calls)请求:

请求顺序tool_call_id工具参数
1c1search_hotels西安 / 10-01
2c2get_weather西安 / 10-01
3c3get_weather成都 / 10-01

从业务逻辑看,调用 c3 纯属大模型幻觉——目标城市明明是西安,这在后续规则检查环节注定被拦截。但在当前并发调度循环里,引擎的核心职责是无条件把这三个调用异步分发出去,并确保最终的请求与响应严格配对、不断链。

若走传统串行执行,系统尾延迟将是三次网络 I/O 耗时的总和。为解决这一性能瓶颈,我们引入轮内并发(Intra-turn Concurrency):所有只读工具在同一个异步事件循环中并发跑起来,整体尾延迟被压缩到最慢那个接口的耗时。而像 book_hotel 这类强副作用写操作则绝不参与并发——它不仅需要前置的人工确认拦截,确认后也必须进入互斥锁单步执行,杜绝在同一轮次与其他状态变更操作发生竞态。

最棘手的问题在于,并发调度下工具执行完毕的物理先后顺序,往往会完全偏离模型最初发起请求的逻辑顺序。假设全局超时阈值设为 0.4 秒:c2 耗时 0.05 秒极速返回,c1 耗时 0.25 秒,而 c3 网络阻塞直接睡过时限。若底层代码简单粗暴地”谁先完成谁先追加进 messages”,下一轮请求大模型 API 时必然吃 400 Bad Request——大模型服务端的 AST 解析与上下文校验严格依赖 tool_call_id 配对:既然前一轮 assistant 角色按序声明调用了 c1、c2、c3,紧接着的 tool 角色消息就必须严格对齐这个顺序。因此哪怕 c2 率先完成,调度器也得把它缓存挂起,等前面的坑位填满。

results = {}  # 调度字典,缓存并发执行的结果
# ... 开启异步并发调度,透传执行工具 ...
for item in pending:  # pending 严格遵循大模型原始的请求顺序
    messages.append({
        "role": "tool",
        "tool_call_id": item["id"],
        "content": json.dumps(results[item["id"]], ensure_ascii=False),
    })

无论真实完成顺序如何,最终回灌顺序被强制打平为 c1 → c2 → c3。即使 c3 最终超时,其返回的 content 也会被兜底构造成 {"error": "超时"} 强行占位。这一步万万不可省略,否则上下文就会断层。

左边是一轮三个 tool_calls:c2 先完成,c1 随后完成,c3 超时,回灌顺序仍是 c1 → c2 → c3。右边是多次 run:每次 run 一份 messages 和记忆,轨迹日志带 run_id 当场打出,审计每轮留下未压缩的 messages,确认之后带同一份续跑。写操作单独执行、带幂等键。

必须强调的是,所有安全护栏机制依然前置于并发执行阶段。无论是陷入循环打转的无效调用,还是未经用户确认的危险写操作,在路由分发之前就会被护栏拦截,根本没有机会进入轮内并发的调度池。池子里跑的,全是经护栏放行的合法请求。

二、状态机流转的红线:严格区分超时、打转与退避

一线实战中,不少开发者把超时、打转与退避混为一谈,导致底层状态机流转混乱不堪。

当某个工具调用超过预设的墙上时钟限制时,其处理逻辑应等同于”参数校验失败”——立刻构造一条标准错误回灌给模型:

{"error": "超时"}

如前文所述,一旦漏掉这条回灌,下一次上下文就会因配对失败引发 400。把超时信息透传给模型后,模型在下一轮可能智能地放弃查询成都,或干脆放弃工具调用直接给出终答。必须认清:试图通过修改参数去挽救一个已经丢失的进程毫无意义,进程丢失属于”异常终止”范畴,而工具超时属于正常的”状态回灌”环节。

若是请求大模型 API 本身发生超时,情况则截然不同。此时上下文中还没有产生这一轮次的 assistant.tool_calls,自然也无需后续 tool 角色去配对。正确做法是直接走异常出口、触发状态机终止。切忌在代码里糊弄——既不要傻等,也不要为了”补齐结构”伪造一条假的 tool_calls,那只会把整个上下文彻底污染。

异常状态触发节点兜底策略
工具超时当前工具调用的物理时间耗尽阻断并回灌 {"error": "超时"}
模型超时推理 API 响应超时,缺失 tool_calls触发异常出口,直接终止当前 run
指数退避工具内部遭遇上游限流 429 或报错 5xx在工具层封装重试机制,穷尽后兜底回灌错误
逻辑打转模型连续两次吐出相同的工具与参数组合护栏前置拦截,拒绝执行并强制终止

架构层面必须把这三种状态清晰剥离:逻辑打转是模型陷入同参调用的死循环,护栏须在物理执行前就将其斩断;工具超时是请求已透传出去并处于执行中,但系统时钟已耗尽;指数退避则是进程仍在等待上游响应、尚未触及全局超时红线。

回到前述并发案例,c3 就属于典型的工具超时。此时调度器必须确保主循环坚挺,把 c1、c2 的真实结果与 c3 的超时回灌一并打包,平滑推进到下一轮推理。绝不能因其中一个接口偶发超时,就把同轮次其他工具辛苦跑出的结果一并抛弃——那是对算力和网络资源的纯粹浪费。

三、分布式写操作的梦魇:状态未决与幂等性兜底

get_weather 这种无状态纯读操作,遇到网络超时大不了重试一次。但换成 book_hotel 这种涉及状态变更与库存扣减的强副作用写操作,一旦超时,整个系统就陷入状态未决的梦魇:对端可能已悄悄扣减库存并生成订单;直接抛失败可能导致漏订;用相同参数简单二次重试,又极可能酿成”双重预订”。此时仅向模型回灌一句 {"error": "超时"},虽保住了上下文的结构配对,却根本回答不了业务核心问题——“这笔订单到底落盘了没有?”

为消除这种不确定性,所有底层写操作必须强制注入幂等键。具体做法是将当前会话的 run_id、目标 hotel_id 与入住日期哈希拼接,合成一个全局唯一标识,透传给下游 book_hotel 接口。下游服务一旦识别出重复幂等键,只会优雅返回已存在的订单快照,绝不视为一笔新交易。

更关键的是,写操作触发超时异常后,绝不应无脑重试 book_hotel。正确的架构流转是:立刻降级,先查这把幂等键是否已关联真实订单。

不要天真地以为用户一点「确认」,模型就能肆无忌惮地换着参数反复冲撞接口。系统底线在于护栏永不缺席:未获确认的写请求一律不予放行;即便已确认,也必须通过互斥锁确保一次只处理一个,并死死焊住那把唯一的幂等键。

四、沙盒隔离:并发请求的上下文截断与独立运行

前文探讨的”轮内并发”只局限于单一 run 会话内部的工具调度。但在高并发的真实场景中,当两条毫无关联的订房请求(如”西安预订”与”成都预订”)同时涌入网关时,底层会被拆分为两次完全独立的 run 实例。

为防止上下文污染,西安会话必须有专属的 messages 数组,成都亦然。全局记忆池、累计 Token 计数器、用户人工确认状态以及至关重要的幂等键,统统必须强绑定到各自专属的 run 实例上。试想,若图省事把记忆状态糊在进程级全局变量里,西安任务搜出的 hotel_ids 列表就会穿透并污染成都任务的上下文空间。灾难场景可能是:成都会话莫名其妙锁定了并不属于它的 HT-002 酒店,而护栏代码审查时,由于该 ID 确实来自某次合法的 search_hotels 历史调用,参数校验竟直接过关——只可惜它根本不是本次 run 产生的合法数据。

因此每次全新 run 被拉起推入调度循环时,必须严格创建一个沙盒化的全新记忆结构;若是中断恢复,则通过 run_id 找回并重载上一轮落盘的那份快照。绝不让下一次订房会话”偷窥”到上一次任务残留的 hotel_ids、confirmed 状态或历史 Token 消耗。

作为系统流转的生命线,run_id 必须在任务首次进入主循环时就生成。随后所有轨迹日志、审计留痕、幂等键生成以及未来断点续跑,都将高度依赖这个唯一标识。永远别等到数据打结、系统崩盘时,才去日志里捞那些残缺的上下文。

五、工程底座:轨迹打点、无损落盘与断点续跑

所谓”终答”,不过是状态机运行到终点出口时抛给用户的最后一句输出。真正决定系统健壮性的,是当故障不可避免地爆发时,你能精准定位出这是哪一次 run、停留在哪个调度轮次、卡在哪个特定工具调用、下发参数长什么样、最终回灌的负载又是什么。

为此,系统必须在每个轮次运转中,当场向控制台或日志服务打出带 run_id 的轨迹日志:

[run run-xian turn 1] get_weather({"city": "成都", "date": "2026-10-01"}) → {"error": "超时"}

这不仅是排错的”救命稻草”,更是线上系统的心跳。通过当场查看这些规整的日志打点,工程师能在第一时间判定当前异常究竟是上下文配对断裂、单纯网络超时,还是模型陷入死循环的逻辑打转。若没有统一透传 run_id,西安和成都的并发日志会在标准输出里绞成一团,根本无从分辨哪一单被超时卡死。

不仅如此,同一份 messages 数组必须伴随 run_id 周期性序列化落盘,这就是严谨的审计机制。请注意,审计绝不是事后生成一个模糊摘要,而是要把当时系统内部那组原始 messages(包括 System、User 提问、携带 tool_calls 的 Assistant 输出,以及严格按请求顺序回灌的 Tool 响应)原封动物理落盘。每一轮都要写一次,绝不拖延到最终异常出口。如此一来,即便进程在结果回灌完毕、尚未给出终答的脆弱间隙突然挂掉,调度系统仍能在下次拉起时从快照中无损读回。

发给模型的若是压缩后的副本,审计务必留下未压缩的那份原件。因为压缩算法往往把旧 tool 返回体替换成精简摘要,一旦丢失原有的 tool_call_id 和细粒度错误栈,事后那些依赖 AST 解析的护栏与离线校验工具就会彻底失效。

当用户终于在界面点击「确认」,系统要做的仅是将那条代表授权的 User 消息追加到这份刚读回的 messages 快照尾部,然后触发断点续跑。它带着那个熟悉的 run_id、连贯的 messages 历史与完整记忆状态,平滑重新切入主循环。坚决抵制为处理回调而新开一个轻量级 run 的偷懒做法。只有当底层护栏代码通过解析上下文、清晰看到那条授权消息时,才会把那把宝贵的幂等键授予 book_hotel 工具、予以执行放行。

即便在高并发场景下,两次隔离的 run 同时向磁盘写审计快照,也必须各自写入相互独立的文件分片。run-a 的日志里绝不容忍出现哪怕半点属于成都请求的脏数据。通过将静态审计文件与动态轨迹日志按 run_id 严格对齐打平,事后无论何时把这些物料丢进 extract_calls 离线校验管道,都能随时重放历史,让所有护栏和规则检查严丝合缝地再跑一遍。

六、打通底层状态机的终极流转

在之前的架构演进中,我们在核心循环的外围逐个加装了装甲。如今把视角收束到一次独立完整的 run 实例,整个状态机的流转顺序已被固化为不可逾越的铁律:

  1. 上下文初始化:任务切入主循环时强制注入并透传 run_id。若为全新目标则分配一块干净的记忆内存区;若是等待确认后的唤醒操作,则根据 run_id 将审计快照中的 messages 与记忆原模原样地反序列化读回。
  2. 状态快照与推理准备:每个调度轮次开启时,首先将记忆池的最新状态落盘,随后经上下文截断/压缩生成即将发往大模型的副本,接着发起推理请求。若此时发生问模型超时,则直接触发进程终止。
  3. 终态判断与挂起:若模型未返回任何 tool_calls,则视为该轮次已输出终答。若该终答意图是等待用户确认写操作,则立即将 messages 完整落盘并主动挂起进程,静候回调,严禁为此新拉起一个独立的 run。
  4. 前置护栏审查:若模型返回了 tool_calls,则全数提交给前置护栏进行静力学分析。一旦查出逻辑打转则强制终止本次 run;若是尚未获取用户确认的高危写操作,则直接在当前轮次阻断执行并回灌错误,坚决不透传给底层执行器。
  5. 轮内并发与严格回灌:被护栏放行的只读工具群,被抛入并发调度池异步执行,同时设置严格超时阈值。执行完毕后,回灌内容必须被强行打平并对齐到模型原始请求顺序(挂起缓存以等待慢接口)。在执行任何回灌动作之前,务必只保留最小的必要字段结构以降低序列化开销。
  6. 副作用写操作的安全降级:写操作被剥离出并发池进行单步互斥执行,且强制注入幂等键。若不幸遭遇超时,必须首先降级执行状态查询订单,再依据查询结果谨慎决策是否推进真正的下单动作。
  7. 数据打点与审计落盘:每一轮次执行完毕,当场打出一行携带 run_id 的系统轨迹日志,并依照相同标识将审计快照完整落盘。
  8. 算力预算管理:每次 run 独立维护自身的累计 Token 消耗水位线,一旦超标则熔断终止。坚决杜绝两次不同的 run 实例互相污染计数器。

在这一整套森严的底层流转中,模型始终只负责一件事——“告诉我你想调哪个工具”,而稳定落地的千斤重担,全压在我们的调度循环上。

七、一线踩坑指南:当架构预期与现实脱节时

线上异常表象架构排查的第一落脚点
服务端拒答报 400,tool 角色结构无法配对 tool_call检查底层回灌逻辑,是否错误地按”完成先后”追加,而未对齐大模型的”请求顺序”
一轮派发三个工具,总耗时却接近三者之和轮内并发调度未生效或被死锁阻塞,底层协程依然在串行等待
并发中仅有一个工具超时,却连带导致另外两条合法记录丢失回灌逻辑中缺少占位兜底,务必确保三条记录全部回灌,超时那条用兜底错误报文填充
西安订单的 hotel_id 幽灵般窜入成都任务的上下文发生了惨烈的沙盒隔离失败,两次并发 run 错误共享了进程级的记忆单例
工具线程假死挂住,既无法推进回灌也不触发状态终止缺少强力的超时打断机制,必须强制阻断并主动回灌 {"error": "超时"} 逼迫状态机流转
问模型迟迟未获返回,线程持续处于等待状态此为模型级异常,直接触发全局出口终止本次 run
遭遇 book_hotel 超时后,再次重试却导致用户被扣双份钱检查是否透传了幂等键;务必在超时后先走查单逻辑,确认未落盘后再决定是否重新下发写操作
用户已回传「确认」,但系统唤醒后发现记忆池被清空典型的恢复断层:没有读回同一份持久化的 messages 续跑,反而错误地新开了一次全新的 run 实例
满屏日志互相穿插,根本无法将报错追溯到具体一笔订单轨迹日志未做到规范打点,必须在日志头部强制追加打印 run_id
离线环境检查无法重跑审计落盘的并非最原始的 messages 上下文,或是手误只存入了被压缩算法精简过的残缺副本
用户尚未授权,系统却已经将订单落盘这是前置护栏机制的彻底失效,并非轮内并发的问题,重点核查护栏的 AST 拦截规则

请牢记这些底层开发的血泪教训:遇到超时,不要脑抽去盲目增加循环上限(max_turns);遇到串行慢,不要第一时间就试图换大模型;发现记忆互相串线,不要急着去修改数据 Schema;而当写操作发生超时,更不要凭空臆测将其归类为逻辑打转。

八、核心架构实录代码

为保持文章焦点的纯粹,本实例全程未接入真实 LLM 接口调用。完整的底层调度器实现已开源于 agent-in-action/part1-handmade-agent/08-agent-deployment/run.py。你可以直接执行 python run.py 来观测四段极具代表性的硬核状态机流转:

  1. 模拟一轮三个 tool_calls 的并发碰撞:重点观察完成先后是不是 c2、c1、c3,并验证最终向 messages 回灌时,系统是否如同铁闸般死死捍卫住 c1 → c2 → c3 的原始请求顺序;同时确认 c3 确实因超时而被精准填补了 {"error": "超时"} 的兜底信息。
  2. 实战写操作在超时边缘的反复横跳:人为构造 book_hotel 完成订单落盘却瞬间睡过时限的极限场景。虽然调度器这头如实回灌了 {"error": "超时"},此时底层真实订单数已达 1。接下来的逻辑流转将展示系统如何冷静地通过查询同一把幂等键,命中已有订单并阻止二次预订尝试。随后,你用同一把键强行再次发起预订,见证系统仍然稳如泰山地返回 BK-001 历史快照,使得总订单数死锁在 1。
  3. 断点状态的安全挂起与恢复:系统在第 1 轮完成回灌后执行常规审计落盘,随后模型输出需要确认的终答触发二次落盘,最终整个 messages 被静静封存在 audit/run-confirm.json 中。紧接着,模拟外部传入一条 User 角色”确认”指令的唤醒回调,系统将读回来接上这条指令,用同一个 run_id 强势续跑,最终在护栏注视下安全放行并执行 book_hotel 操作。
  4. 沙盒隔离下的高并发落盘:强行拉起两个并行的 run 实例同时写审计,仔细校验 run-a 的日志分片中只有西安,未曾混入半滴属于成都 run-b 任务的数据泥沙。

如果你试图搞点破坏——比如将第 2 段里的 find_booking 查单逻辑跳过,让调度器在超时后直接再 book_hotel,那么恭喜你,订单总数将变成 2,一场生动的”双订”事故就此诞生。又或者,你把第 3 段中的续跑逻辑修改为新建 messages,那么之前苦苦暂存的 hotel_ids 以及用户的确认授权态,将在瞬间丢失。

当你准备将这套逻辑接到现有循环里时,请严格按照上一节那八步换掉执行逻辑。务必确保每次 run 都有新建或读回记忆的动作,且在任务进循环的第一时间,必须无条件带上 run_id。

九、接下来

一路走来,我们在这套纯手工打造的底层循环上,逐层加装了重型装甲:从底层的 Schema、上下文压缩、记忆,到计划、检查、护栏、并发、超时、幂等键、审计乃至续跑。这些组件分开看都成立,然而装在一起时,还差最后一件事——那就是在单一调度轮次内,它们究竟应当按什么顺序发生。下一篇我们将把它们无缝熔接回同一份循环中,从进循环一路跑到停住等确认,再演示带断点状态完美续跑并成功完成下游下单的完整过程。

极简架构总结

剥离了黑盒框架后,底层调度的核心铁律在于:一轮里多个 tool_calls 必须实现并发调度,但结果回灌必须被强行打平并对齐大模型的请求顺序。 工具层面的执行超时透传并回灌错误,而问模型推理超时则触发全局异常终止。分布式写操作面临超时困境时,必须实施”先查询校验再决策落盘”的降级策略,并强制依赖幂等键。无论是逻辑打转,还是遭遇未经用户显式确认的高危操作,都统统交由前置安全护栏在实际执行的物理边界外提前斩断。

每次并发的 run 实例都必须维护一份专属的 messages 数组。 轨迹日志当场打出并带上 run_id,审计每轮强制留下未压缩的 messages 原始快照。确认之后续跑,事后随时能将审计日志拉出来再跑检查。


–views
Share this post on:

Previous Post
拼回同一份循环:一次 run 的全生命周期
Next Post
检查通过仍会打转:护栏拦截与累计 Token