1. 题目分析
在 Agent 执行任务时,经常需要等待外部 LLM、搜索服务或工具返回。等待期间 CPU 虽然不持续消耗,但请求携带的上下文、连接和执行状态会一直占着资源。并发任务越多、下游响应越慢,这种存量积累就越明显,往往最先耗尽的是内存或连接池,而不是计算能力。
异步编程能减少线程阻塞,却不会自动释放任务引用的数据。一个挂起的协程照样可能保留召回文档、完整 Prompt 和流式响应对象;大量协程同时等待时,资源占用依然会累积。所以把同步调用改成异步调用,只解决了等待成本的一部分。
真正治理要回答三个问题:允许多少任务等待、等待期间必须保留什么、哪些任务能保存状态后先退出当前执行进程。不同等待阶段的处理方式不一样,首先要区分排队等待、在途调用和异步业务等待。
1.1 算清等待占用
排队、在途调用和业务等待,要分开统计。排队任务还没调用下游,只需保存任务标识和输入引用;在途调用已经发出,需要维护网络流和必要上下文;业务等待可能只是等一个外部作业结束,有机会把执行现场落盘,让 Worker 退出。如果这三类等待都被实现成“创建一个协程然后一直挂着”,就没有分别治理的空间了。
举一个容量例子:某模型接口平均每秒发出 100 次调用,平均耗时从 2 秒拉长到 20 秒。在各自稳定、统计边界一致的前提下,平均在途数大概从 200 增至 2000。如果每次调用额外保留 256 KiB 数据,仅这块存活对象就从约 50 MiB 增长到 500 MiB,还没算连接缓冲和运行时开销。这只是估算,不是生产测量;持续过载、尚未稳定时,也不能直接用这个关系预测峰值。
所以治理对象不是抽象的“异步任务太多”,而是有多少任务停在哪一步,每个任务还占着什么,占到什么时候。排查时把任务数和堆内存、线程、文件描述符、连接池等待对应起来,才能判断到底是阻塞线程过多、上下文保留过大,还是在途调用没有上限。

挂起任务仍然持有上下文和网络资源
这笔账决定优化顺序:先避免等待独占执行线程,再限制等待任务的存量,随后缩小每个任务的持有范围。只有外部协议支持恢复时,才进一步把长等待移出进程。
1.2 释放执行线程
如果等待先占满的是线程池,就要把网络调用改成真正的非阻塞 I/O。调用发出后,运行时保存恢复所需的现场,把执行线程让给其他就绪任务;数据返回时再调度原任务继续。这里释放的是线程的等待时间,不是原任务的全部资源。
判断是否真正非阻塞,不能只看函数前面有没有 async。异步函数内部如果调用同步 HTTP SDK,照样可能阻塞事件循环;大段 JSON 解析、文档处理等 CPU 密集工作,也会让其他任务迟迟得不到调度。Python asyncio 文档明确提到,在事件循环线程中直接执行阻塞计算,会延迟其他任务和 I/O。
暂时无法替换的同步 SDK,可以放到独立、有界的线程池,但提交入口也必须限量。只把线程数设为 20,再往执行器里塞进十万个任务,只是把阻塞转移到了队列和 Future 对象上。取消调用方的等待也不能当成后台线程已经停止:Future 的取消规则不允许通过一次取消操作终止已经开始执行的工作。

非阻塞执行释放线程但保留任务现场
完成这一步后,线程数可能降下来,内存却继续上涨。这不矛盾:线程可以复用,但挂起任务引用的 Prompt 和检索结果还在。接下来要控制的是任务存量,而不是继续增加协程数量。
1.3 限制等待存量
限制必须发生在创建重量级执行任务之前。假设模型只允许 100 个在途调用,入口却先为十万个请求创建协程、加载历史、拼好 Prompt,再让它们去竞争 100 个许可,那么调用数虽然受控,等待区仍可能先耗尽内存。
更合理的路径是:入口先做准入,任务以轻量描述进入有界队列;调度器有承接能力时才取出任务、加载当前步骤需要的数据,并申请下游调用许可。队列里只保存 run_id、step_id、截止时间和输入引用,不把完整对话与文档副本带进去。

先准入再创建执行任务
这里至少有两条边界。第一条限制系统接纳多少等待工作,要同时考虑条数、字节和等待时间;第二条限制实际发出多少调用,按模型、检索、Tool 分别控制。父任务受控后,子分支也不能无限展开,否则一次检索扇出就能绕过入口限制。
队列满了,要按接口契约拒绝、限时等待,或转入已经具备持久化承接能力的异步流程。不能不断创建新的生产者任务,让它们阻塞在入队操作上:队列长度没有变化,队列外的等待者却一直增长。客户端请求体的读取和大小限制也应前置,不能等解析完整段巨大输入才判断是否接纳。
另外,连接池大小不能代替调用并发限制。HTTP/2 可以在连接上复用多个请求流,所以“只有几十条连接”并不意味着只有几十个在途调用。准入控制的是工作存量,调用许可控制的是下游压力,两者都需要独立观测。
1.4 缩短资源持有时间
任务数有了上限,还要避免一个任务在等待模型时占住那些本来就稀缺的资源。否则 100 个合法的在途调用,也可能同时扣住 100 条数据库连接,拖慢完全无关的状态查询。
以“读取会话—调用模型—保存结果”为例,读取状态应在短事务中完成,然后结束事务、归还连接;网络等待期间只保留必要的状态快照和版本号。模型返回后再开一个短事务,校验版本、提交结果。不能为了防止别人改会话,就把数据库事务跨越几十秒的模型调用;版本冲突应由写入规则处理。
与数据库连接相反,模型在途许可必须覆盖整个模型调用。协程进入等待时若把许可归还,新请求会继续发出,实际在途数立刻突破上限。流式响应尤其容易出错:收到响应头不代表调用结束,许可要到响应流消费完成或本地调用清理结束时才回收。远端是否真的停止,还需另行确认。

不同资源按各自生命周期持有
内存也按这个思路处理。当前步骤不再需要的召回原文要及时解除引用,大型 Tool 结果保存为受控对象引用,下一步按需读取;日志和追踪记录不要长期复制一份完整 Prompt。只改一个局部变量还不够,如果闭包、缓存或日志队列仍持有对象,它就不会被回收。
共享 HTTP 客户端可以复用连接,但每次响应流都要在成功、异常和取消路径中关闭。HTTPX 的异步流文档专门提醒手动流模式需要负责关闭响应。复用客户端与及时关闭单次响应并不冲突,前者减少连接创建,后者防止资源泄漏。
1.5 把长等待落盘
经过前面几步,普通网络等待已经变成“数量受限、现场精简的在途调用”。但如果 Tool 启动的是一个耗时十分钟的外部作业,还用协程干等十分钟,就值得进一步改变执行方式:提交之后保存恢复依据,退出当前 Worker,收到完成事件时再继续。
这一步的前提是外部作业脱离当前连接仍能执行,并且可以重新定位结果,例如提供稳定的作业 ID、状态查询或可靠回调。普通 LLM 流式连接没有这种能力时,不能把协程状态序列化一下,就宣称连接也能无成本恢复。
满足条件后,先持久化检查点、操作标识和待提交事件,再由提交器发起外部作业。这样即使 Worker 在提交前后宕机,也能根据同一个操作标识查证或幂等提交,而不是丢掉任务或盲目创建第二份作业。任务记录保存下一步要恢复的位置、外部关联标识及截止时间,内存中不再保留完整执行栈。

长等待落盘后通过可靠事件恢复
回调路径同样要可靠。先把收到的事件持久化,再确认接收;推进任务状态和安排恢复之间,也不能留一个“状态已改,通知丢了”的窗口,可以通过事务内的待调度记录衔接。回调可能比任务写入等待状态更早到达,因此要能按操作标识暂存、匹配,不能只接受已经标成 WAITING 的任务。
没有回调但可以查询时,用共享调度器扫描到期记录,按退避和限额查询,不为每个任务创建一个常驻轮询协程。Temporal 的异步 Activity 完成机制也体现了这种分离:执行函数返回与业务活动完成可以是两件事。真正节省资源的,是让业务等待由持久化状态承接,而不只是换一种 await 写法。
1.6 处理慢消费与取消
还有一种等待发生在结果已经返回之后:模型持续生成,用户网络却很慢。此时如果读取端不断接收 Token,再放进一个无限队列,前面的并发控制仍然挡不住单个流持续积累内存。
应为每条流和所有流合计设置字节预算。达到水位后,让读取或生产过程等待消费端腾出空间;持续超过时限,再按产品约定终止交付,或者把后续结果保存到持久化存储,允许用户稍后获取。Node.js 的流背压说明展示了忽略写入反馈导致缓冲持续积累的问题。具体实现仍要核对 SDK 是否提前预读,水位阈值也不能被误当成进程内存的绝对硬上限。

慢消费者触发缓冲限制与退出策略
暂停本地读取,不等于远端模型暂停生成或停止计费。因此,背压解决本地缓冲,取消策略处理是否还要继续执行,两者不能混为一谈。用户断开后,短时交互任务可以按约定取消;已经接纳的后台业务任务,可以继续运行并保存结果,不能简单把连接生命周期当成任务生命周期。
一旦决定取消,要停止派发新的子调用,关闭响应流,清理定时器、缓冲和引用,并归还本地许可。对已经发出的有副作用 Tool,取消等待并不能证明业务没有发生;迟到的成功结果仍应写入操作记录,交给后续查询或补偿流程处理。
1.7 验证回收闭环
验收要沿着最初的资源账逐项对应:排队任务是否有界,在途调用是否受控,单任务保留字节是否下降,完成或取消后资源是否能回收,持久化等待是否还能被及时唤醒。只看 CPU 和总 QPS,会漏掉这个问题的主体。
压测时保持外部到达速率,逐步拉长下游耗时,再注入永不返回、同步 SDK 阻塞、慢客户端和重复回调。预期结果不是“所有请求都继续接收”,而是系统在容量边界内运行,超出的请求得到明确处理,已经接纳的任务仍有可查询状态。恢复时还要测试大量等待任务同时就绪,确认它们仍经过有界调度,而不是一次性把上下文全部加载进内存。
重点观察各阶段等待数与年龄、存活堆对象、事件循环延迟、线程和连接池占用、取消后的残留任务,以及回调到重新执行的延迟。资源释放后,进程 RSS 不一定立刻下降,所以应结合堆分析判断对象是否仍被引用,而不是只盯操作系统的内存曲线。
最终形成的闭环是:少接纳不必要的等待,少持有等待中的资源,能落盘的等待不常驻,退出和恢复都重新经过容量控制。 异步 I/O 只是这个体系的起点。
2. 参考回答
我会先区分排队等待、在途调用和外部业务等待,统计各阶段的任务数、保留内存、连接占用和等待时间,定位到底哪类资源先耗尽。
执行层使用真正的非阻塞 I/O,同步 SDK 放进有界执行器,但不把协程当成免费资源。准入要放在创建大量任务和加载上下文之前,排队只保存轻量描述,并分别限制队列容量、子任务展开和下游在途调用。
资源按阶段持有:数据库读完就归还连接,网络等待不跨长事务;模型许可则覆盖完整调用,不能一进入等待就释放。大对象按需加载,响应流在成功、异常和取消路径都要关闭。
对于有作业 ID、查询或回调能力的长任务,我会保存检查点和操作关联,把等待落盘,通过可靠事件恢复;普通流式连接不能假装支持这种挂起。慢客户端还需要字节级背压与超时退出。
最后用下游变慢、永不返回、集中唤醒和取消故障验证资源有界、回收完整,并区分本地取消与远端业务结果。