AI编排器防失控:看门狗与熔断器机制深度解析
1. 项目概述:为什么我需要一个会"踩刹车"的编排器
先说个我自己的糟心事儿。去年秋天做个调研型 Agent,任务很简单:让它把二十几篇论文的核心观点整理成表格。头一天晚上把任务挂上,第二天早上起来打开账单一看,一夜之间烧掉一百多美元。日志翻了半天才发现,Agent 在第 43 步左右就开始原地打转,反复对同一篇论文做"总结→评价→再总结",每次措辞都不一样,但做的事完全重复。模型显然不知道自己在绕圈,也没有任何机制阻止它绕圈——因为根本没人给它踩刹车。
这个项目就是这个背景下长出来的。我要做的这个 AI 提示流编排器(Prompt Flow Orchestrator),本质上是一个把大模型调用、工具调用、条件分支、循环逻辑组织成有向图并驱动执行的可控运行时。大模型是这个编排器的"大脑",但它是一个概率性的大脑,有时候会陷入重复。所以这个系列第 13 篇的核心任务,就是把上一篇文章里图形执行引擎那层架子,加上两个真正能拦住失控的安全件:运行时看门狗(Runtime Watchdog)和死循环熔断器(Infinite-Loop Circuit Breaker)。
这两个东西到底解决什么问题,一句话说清楚:看门狗负责回答"这个流程现在还活着吗、还活着就还有没有预算",熔断器负责回答"它是不是在机械地重复同一件事、如果是就立刻切断"。目标只有一个——别让 Agent 在没人盯着的时候刷爆你的信用卡。
这篇博文适合谁看?如果你正在开发多步骤 LLM 应用,或者用各种 Agent 框架搭过自动化流程,又或者你已经被"Agent 死循环"坑过但不知道从哪下手修,这篇文章应该能帮到你。我不准备泛泛讲概念,会把整个设计决策、核心数据结构、关键代码、踩过的坑全部摊开来讲。
2. 整体架构设计:编排器、看门狗、熔断器各司其职
2.1 编排器的核心模型:从"线性流水线"到"可控图执行"
在动手做安全机制之前,得先搞清楚编排器内部是什么样子。我一开始的方案其实很幼稚:一个接一个地按顺序调用大模型,上一步的输出作为下一步的输入。这东西对付简单的"翻译+润色"流程没问题,但一旦流程里有条件分支、重试、人工审批、并行的工具调用,线性流水线就完全不够用了。
所以我改成了有向图模型。图上的节点分几种类型:LLM 节点负责调用模型;Tool 节点负责执行具体的函数调用(比如查数据库、调天气接口、搜索网页);Router 节点负责根据条件决定下一步走向哪条边;Human 节点负责暂停流程等待人工输入。每条边代表数据的传递关系,节点执行完后把结果写进一个共享的执行上下文(Context),下一个节点从里面读取自己需要的内容。
选有向图而不是线性流水线,背后有一个实在的理由:Agent 的核心能力是"根据中间结果动态决定下一步做什么",这个决策本身就是一张图。你让 Router 节点根据大模型的判断在几条边之间跳转,图模型天然支持;你让线性代码动态跳来跳去,写起来就是一堆屎山般的 if-else。
但图模型引入了一个致命问题:图可以有环。只要有环,就有死循环的可能。这正是后面看门狗和熔断器存在的前提。
2.2 安全机制定位:为什么不做进节点内部,而是放在执行引擎层
设计安全机制时最重要的一条决策,是把看门狗和熔断器做成执行引擎层的横切组件(Cross-Cutting Concern),而不是塞进每个节点的实现里面。
如果每个节点自己检查预算、自己判断是否超时,会发生什么?第一,每个节点作者都得写一遍同样的逻辑,十有八九有人忘写;第二,节点内部的检查只对单个节点有效,拦不住"节点间反复横跳"这种全局性的死循环;第三,业务逻辑和资源控制耦合在一起,代码根本没法维护。
我的方案是在执行引擎外层加一道"监督层"(Supervision Layer)。核心执行循环长这个样子:
while not flow.done(): watchdog.check() # 运行态检查:超时、停滞、预算 breaker.check(flow.snapshot()) # 熔断器检查:是否死循环 node = flow.next() # 从图里取下一个节点 result = await execute_node(node) breaker.register_step(flow.snapshot()) # 记录这一步的指纹 watchdog.kick(node.name) # 踢狗:报告我还活着这个循环的每一条语句都不是乱写的。watchdog.check()在节点执行之前先看全局状态,如果整体超时或者预算耗尽,直接抛异常中止;breaker.check()看的是"当前流量快照"在模式上是否已经重复了太多轮;节点执行完之后,register_step()把这一步变成一串指纹存进历史;kick()则更新心跳时间。
这种"前置检查 + 后置记录"的模式,保证了无论图里跑的是什么类型的节点,都逃不过监督层的眼睛。你不需要信任任何单个节点的自觉性——这正是"别让 Agent 刷爆信用卡"的核心工程哲学:对概率性的模型永远保持怀疑,用确定性的代码兜底。
2.3 方案选型对比:别的做法为什么不够用
在社区里逛了一圈,很多 Agent 框架解决失控问题靠的是"在提示词里写一句:请勿重复执行"。这玩意儿有用吗?有一点,但完全不够。大模型对约束的遵循是概率性的,压力一大、上下文一长,它就忘了。我见过有团队给 Agent 加了"max_iterations=10"的硬上限,结果死循环确实被卡住了,但正常的任务也被卡住了——有些合法任务的步骤天然超过 10 步。
另一个常见做法是"人工盯着"——在人机交互界面里看到 Agent 不对劲就去点停止。对小规模试用可以,一旦流程并行跑 20 个分支,你根本盯不过来。
所以我坚持用"看门狗 + 熔断器"的组合,而不是单一机制。两者分工完全不同:看门狗管的是"时间和钱",这两个资源是连续变量,适合用阈值判断;熔断器管的是"行为模式",这是一个离散的模式识别问题,适合用状态机和指纹哈希解决。把两个不同性质的问题混在一个系统里处理,必然两头都不讨好。
3. 运行时看门狗设计:三层检测让 Agent 无处遁形
3.1 看门狗概念的源头:从嵌入式世界偷来的思路
"看门狗"(Watchdog)这个词最早来自嵌入式系统。单片机跑飞了怎么办?硬件上有一个定时器,主程序正常运行的时候必须每隔一段时间去"喂狗"(重新计数值),一旦程序卡死或者跑飞,定时器溢出,直接触发硬件复位。
这个思路搬到 LLM 编排器里,核心逻辑完全一致。我把嵌入式里的"喂狗"动作对应成执行循环里的kick(),把"复位"对应成"中止当前流程并触发兜底逻辑"。这个类比帮了我大忙,因为它提醒我:看门狗的价值不在精确,而在"宁可错杀,不可漏杀"。嵌入式工程师宁可让系统偶尔重启一次,也绝不允许系统带着故障状态一直跑下去。放在这里就是:宁可偶尔误杀一个其实还能出结果的流程,也比让它在无人值守时烧掉一个月预算强。
3.2 第一层:墙钟时间 TTL——最粗暴但最必要的防御
第一个检测维度是墙钟时间(Wall-Clock Time)。每次启动一个流程时,记录started_at时间戳,设定一个全局 TTL(Time-To-Live)。watchdog.check()里每次都对比当前时间和started_at,超过 TTL 就抛出WatchdogTimeout。
这个 TTL 设多少?我一开始设了 10 分钟,结果跑真实任务经常误杀,因为一些工具调用(比如让 Agent 去爬一批网页)确实可能超过这个时间。后来我改成双层 TTL:全局 TTL 取"这个任务的预估时间 × 3"作为兜底,另外给单个节点设置各自的超时时间。
单节点超时同样重要。一个 LLM 调用如果 60 秒还没返回,大概率是网络问题或者服务端卡住了,没必要傻等。我用的是asyncio.wait_for()包住每个节点的执行,超时直接取消任务。
参数怎么定?我给个参考公式:
node_timeout = max(provider_sla * 3, 30s) global_ttl = sum(estimated_node_time) * 3 + idle_bufferprovider_sla是模型服务方承诺的最长响应时间。比如你的调用一般 5 秒返回,那就给 15 秒,留足波动空间。idle_buffer是给人工审批节点留的等待时间,比如 5 分钟。这个公式不够严谨,但它好理解、可落地。真正的 TTL 合理值,一定是在你自己的业务场景里跑几轮真实任务后才能调准的。
3.3 第二层:心跳停滞检测——发现"卡住但不超时"的情况
光有 TTL 还不够。有一种最阴间的故障状态:流程没死,但也没在干活。比如某个工具调用因为网络半开状态一直挂着不返回,或者大模型在无限生成内容但内容都是重复的空话。
针对这种情况,我引入了心跳检测。执行循环每完成一个节点就调用kick(),更新last_heartbeat时间戳。看门狗单独起一个监控任务,每隔 1~2 秒检查一次,如果now - last_heartbeat超过停滞阈值(默认 30 秒),判定流程停滞,触发中止。
如果你好奇为什么用单独的监控线程而不是在执行循环里同步检查:因为执行循环里同步检查只能发现"检查那一刻"的停滞,如果某个await调用真的卡死了,代码根本走不到检查点。独立监控线程才能做到"墙外观测"。Python 的 GIL 在这里完全不是问题,因为看门狗的检查频率是 1 秒一次,不是 1 微秒一次。
实现要点是心跳必须是"全局最后一次成功提交"。注意是成功提交才算,如果某一步执行失败并抛了异常,也要记录心跳——因为失败也是一种进展,证明流程还在运转、在报错、在处理。只有"连续 N 秒没有任何事情发生时"才算停滞。
3.4 第三层:成本预算监控——把"钱"变成运行时的一等公民
做完时间和停滞检测,还剩一个大头:成本。信用卡被刷爆的直接原因不是时间超了,而是 token 消耗超出了预期。所以第三层检测是围绕预算(Budget)做的。
我设计了两个层面的成本监控。第一个是调用前预检(Pre-Flight Check):在每次调用 LLM 之前,根据当前消息队列的长度预估这次调用大概要花多少 token,如果"预估成本 + 已消耗成本 > 预算上限",直接拒绝这次调用,进入降级流程。第二个是调用后记账(Post-Accounting):拿到实际的 usage 数据后,立刻把这次调用的真实 token 数和估算金额累加到total_cost上。
计算公式很简单:
estimated_cost = (prompt_tokens + max_tokens) / 1_000_000 * input_price + max_tokens / 1_000_000 * output_pricemax_tokens用你配置的生成上限。如果你调用时没设上限,那预检就没法做——所以强烈建议所有 LLM 节点的调用都显式传max_tokens。
成本预算有一个深层问题:一次任务的预算是多少?我做的是可配置的 DailyQuota + PerRunBudget 双层结构。PerRunBudget 控制单次流程最多花多少钱;DailyQuota 控制一天之内所有流程加起来最多花多少钱。触发 DailyQuota 时,新流程直接不启动,而不是启动到一半再停掉——这能避免"每个流程都花一半预算然后被腰斩"造成的浪费。
这个成本监控不仅对云 API 有用。我后来把编排器接到本地模型(比如 Ollama 部署的量化模型)上跑,发现同样需要预算控制,只是预算单位从"美元"变成了"内存占用和显存时间"。你在本地跑一个模型实例,如果 Agent 死循环,GPU 就被占住,别人想跑别的任务都跑不了。预算机制的接口设计成抽象的CostTracker,云上算美元,本地算算力时,下层实现不同,上层逻辑完全复用。
4. 死循环熔断器机制详解:从模式识别到快速切断
4.1 死循环检测的三种策略:步数、哈希、语义相似度
看门狗解决了"时间超了"和"钱花超了",但它解决不了"明明才 20 步,却一直在原地打转"这类问题。熔断器就是专门干这个的。
怎么判断一个流程在死循环?我用了三条策略叠加。
第一条是最基础的步数上限。整个流程最多执行 N 步(默认 50),超了就熔断。这一步很有必要,因为它是所有策略里唯一一个 100% 确定不会误判的——一个流程执行了 500 步还没结束,不管是不是死循环,它都已经失去可控性了。但对步数上限的局限性也要有清醒认识:一个死循环可能只有 30 步就绕完一轮,周期性地重复,步数上限能拦住的只是"总步数太多"的场景,拦不住"步数不多但完全相同"的场景。
第二条是状态指纹哈希。每执行完一步,我把当前的 Agent 状态序列化成一个结构化的快照:当前节点类型、将要调用的工具名、归一化后的工具参数、最近一段输出文本的关键摘要。然后把快照哈希成一串指纹。如果同一个指纹连续出现 3 次(或者在一个滑动窗口内出现超过一半次数),判定为死循环。
第三条是语义相似度检测。哈希只能抓"完全一致"的重复,但 Agent 死循环时往往每次的措辞都略有不同。比如搜索节点每次传入的 query 从"什么是内存泄漏"变成"内存泄漏的原因是什么",语义相同但字符不同,哈希完全匹配不上。针对这种情况,我用嵌入向量的余弦相似度做二次检测:把当前步骤的"思考摘要"和最近若干步骤的摘要做向量比对,如果连续 3 步以上的相似度都超过 0.92,也判定为死循环。
需要强调的是,语义检测不能替代哈希检测。嵌入计算本身也要花钱,每步都做成本不低。我的策略是:先用零成本的哈希粗筛,只有哈希没有命中但步数超过 10 步时,才触发语义检测。
4.2 熔断状态机:CLOSED、OPEN、HALF_OPEN 的完整流转
熔断器不是"检测到循环就永久拉闸",而是有一个完整的生命周期状态机。我从微服务容错领域经典的 Hystrix 模式里借鉴了三个状态:
CLOSED(关闭):正常状态。熔断器持续记录步骤指纹,计算"循环分数"。分数不是简单的计数器,而是一个带衰减的累计值:每发现一个重复指纹,分数 +30;每出现一个全新指纹,分数 -10(下限为 0)。为什么要衰减?因为 Agent 的正常行为本来就包含大量重复动作,比如一个研究任务里要反复调用搜索工具。如果重复一次就永久计分,正常流程也会被熔断。衰减机制保证了只有"持续重复"才会让分数突破阈值。
OPEN(打开):循环分数超过阈值(默认 100,即约 4 次重复)时,熔断器翻转至 OPEN。此时执行引擎在拿到下一个节点之前就会被breaker.check()拦下,抛出CircuitOpenException。OPEN 不是永久状态,它有一个open_duration(默认 60 秒),计时结束后进入 HALF_OPEN。
HALF_OPEN(半开):半开状态允许流程放行少量试探性的步骤(默认 3 步),看看流程是否已经走出了循环。如果这 3 步都产生了新的、不重复的指纹,说明循环解除,熔断器回到 CLOSED;如果这 3 步里又出现了重复指纹,说明循环还在,熔断器回到 OPEN,并且这次的open_duration应该翻倍(指数退避)。
这个状态机的关键价值在于:它允许我们从死循环里"安全地试探恢复",而不是要么永久停止、要么立刻全速重跑。你可以把它理解成一个电路开关——保险丝熔断了你能直接换一根新的,而熔断器是那种"跳闸后等一会儿自动尝试合闸"的空气开关,没烧断就接着供电,烧断就再跳。
4.3 触发熔断后的降级路径与人工升级机制
熔断触发之后干什么?很多人以为就是"杀掉进程"这么简单。但对真实业务,直接杀掉会丢失中间成果,用户可能会崩溃。
我在熔断触发后设计了一条降级路径,按优先级依次尝试:
- 保存现场:把执行上下文(Context)整体序列化到磁盘,包括所有中间结果、已消耗的成本、执行轨迹。这是第一优先级,因为不到万不得已,用户的中间成果不能丢。
- 尝试收尾节点:如果流程里定义了
on_failure收尾节点,执行引擎会跑一个"最小收尾流程",生成一份已经拿到部分结果的报告。这个收尾流程的预算和步数都被严格限制(例如最多 2 个 LLM 调用,最多 5000 token),防止降级逻辑自己也陷入循环。 - 人工升级(Escalation):如果业务配置了
human_in_the_loop,就触发一个通知,把当前快照、循环证据、部分结果推送给人工审核。这是最后一道防线,也是最有价值的一道——人看一眼就能判断是该放行还是该终止。
我强烈建议每个生产级编排器都把人工升级机制作为标配。自动化的安全系统拦得住 99% 的情况,剩下 1% 的边界情况需要一个人类做最终决策,而这个决策同样应该有迹可循、可审计。
5. 实操记录:核心代码实现与参数调优
5.1 项目结构:把安全组件拆成独立模块
这个系列的项目整体是一个 Python 异步库,第 13 篇新增的代码放在独立的safety包下:
src/orchestrator/ ├── core/ │ ├── context.py # 执行上下文 │ ├── graph.py # 有向图定义 │ └── executor.py # 执行引擎主循环 ├── safety/ │ ├── watchdog.py # 看门狗:TTL + 心跳 + 预算 │ ├── circuit_breaker.py # 熔断器:状态机 + 指纹 │ ├── budget.py # 成本估算与配额 │ └── fingerprint.py # 指纹哈希与语义相似度 ├── nodes/ │ ├── llm_node.py │ ├── tool_node.py │ └── router_node.py └── runtime/ ├── telemetry.py # 步骤日志与指标 └── exceptions.py拆独立的safety包有一个好处:测试的时候可以只针对安全组件写单测,不用拉起整个编排器。我是先用纯内存的模拟节点(就是一个async def返回固定值)把安全组件的逻辑测通了,再接真实 LLM 调用的。
5.2 看门狗实现:心跳、TTL 与预算检查
看门狗的核心类实现,去掉异常处理和日志后大概是这样的:
class Watchdog: def __init__(self, global_ttl: float, stall_timeout: float, budget: Budget): self.global_ttl = global_ttl self.stall_timeout = stall_timeout self.budget = budget self._started = time.monotonic() self._last_heartbeat = time.monotonic() self._current_node = None def kick(self, node_name: str) -> None: self._last_heartbeat = time.monotonic() self._current_node = node_name def check(self) -> None: now = time.monotonic() if now - self._started > self.global_ttl: raise WatchdogTimeout(f"全局 TTL 超时,当前节点: {self._current_node}") if now - self._last_heartbeat > self.stall_timeout: raise WatchdogStall(f"流程停滞,节点 {self._current_node} 超过 {self.stall_timeout}s 无进展") async def preflight_llm_call(self, estimated_cost: float) -> None: if not self.budget.can_afford(estimated_cost): raise BudgetExceeded( f"预算不足: 已消耗 ${self.budget.consumed:.2f}, " f"本次预估 ${estimated_cost:.2f}, 上限 ${self.budget.per_run:.2f}" )请注意check()和preflight_llm_call()是两个不同的入口。check()是执行循环里每步都调用的,负责时间和停滞检查;preflight_llm_call()是在 LLM 节点真正发出 API 请求之前调用的,做的是成本预检。区分这两个入口非常重要:成本预检比时间检查更精细,而且只发生在 LLM 调用之前,工具调用不需要花 token,就不用走成本预检。
5.3 熔断器实现:指纹注册与状态流转
熔断器的核心是状态机加指纹历史。我用collections.deque做滑动窗口,因为它是 O(1) 的队首弹出和队尾追加,而且天然支持"只保留最近 N 步"的窗口约束。
from collections import deque import hashlib class CircuitBreaker: def __init__(self, max_steps=50, duplicate_threshold=3, open_duration=60.0): self.state = "CLOSED" self.loop_score = 0 self.step_count = 0 self.hashes = deque(maxlen=20) self.max_steps = max_steps self.duplicate_threshold = duplicate_threshold self.open_duration = open_duration self._opened_at = None self._half_open_trials = 0 self._half_open_budget = 3 def register_step(self, snapshot: dict): if self.state == "OPEN": return # OPEN 状态下执行循环已经拦截,不会走到这一步 self.step_count += 1 if self.step_count > self.max_steps: self._open("超过最大步数限制") return h = structural_hash(snapshot) repeat_count = sum(1 for x in self.hashes if x == h) if repeat_count >= self.duplicate_threshold: self.loop_score += 30 else: self.loop_score = max(0, self.loop_score - 10) self.hashes.append(h) if self.loop_score >= 100: self._open("循环分数超过阈值") def check(self) -> None: if self.state == "OPEN": if time.monotonic() - self._opened_at > self.open_duration: self.state = "HALF_OPEN" self._half_open_trials = 0 else: raise CircuitOpenException("熔断器已打开,流程被拦截") elif self.state == "HALF_OPEN": self._half_open_trials += 1 if self._half_open_trials > self._half_open_budget: self._open("半开测试期间仍然重复执行") # CLOSED 状态不需要拦截 def _open(self, reason: str): self.state = "OPEN" self._opened_at = time.monotonic() self.loop_score = 0 telemetry.record_circuit_open(reason)structural_hash是这个模块的灵魂。我踩过很大的坑才明白,哈希不能直接对原始快照做,必须先做字段归一化。比如工具参数里带了一个timestamp字段,每次调用都变,那你每次算出来的哈希都不一样,死循环永远检测不到。所以归一化规则是:忽略timestamp、request_id、correlation_id这类易变字段;把query参数做小写化、去空格处理;把max_tokens、temperature这类生成参数单独取出来精确比较。
5.4 参数调优:从测试环境到生产环境的实测记录
这一节分享我在调参过程中拿到的真实数据。测试环境用的是一个 24 步的合法研究流程,生产环境跑的是 12 个并联的 Agent 任务。
先看duplicate_threshold(连续重复多少次算循环)。我一开始设 3,马上发现误判——一个研究流程里 Agent 反复用同一个搜索 query 去查不同页面的第 2、3、4 页,状态指纹完全相同但语义上是合法的"翻页"行为,哈希层面全是重复。后来我把"工具调用的参数归一化"逻辑改成:分页参数page单独拆出来参与比较,而不是一棍子打死。依旧设 3,但配合loop_score的衰减机制,单次重复只会加 30 分,而只要中间出现两个全新指纹,分数就会被打到 0,不会误熔断。
再说loop_score的阈值。100 分意味着至少 4 次连续重复(30×4=120,中间没有非重复步骤)。这个值在实际运行中对我的业务足够灵敏:Agent 死循环时几乎每步都在重复,单次重复往往超过 10 次,分数冲上千都很快。如果你的业务里 Agent 行为本身重复度很高(比如它是一个"依次处理同类单据"的循环任务),建议把阈值调到 200 甚至 300,并且让每次非重复步骤的衰减量更大。
最后是open_duration。60 秒的默认值来自对 LLM 调用延迟的观察:一个典型的 LLM 调用需要 5~15 秒,加上工具调用和上下文处理,3 步试探性执行大概需要 30~60 秒。如果open_duration太短,熔断器打开后很快就进入 HALF_OPEN,但此时外部依赖(比如上游 API 的限流)还没恢复,试探必然失败,熔断器就会在 OPEN 和 HALF_OPEN 之间高频抖动。我线下跑了几轮,发现 60 秒配合指数退避(OPEN 一次后open_duration×2,上限 300 秒)是最稳的。
6. 常见问题与排查实操:那些文档里不会写的坑
6.1 误报与漏报:看门狗的两难
第一个让我头疼的问题是误报。有一个带人工审批节点的流程,Agent 跑完分析后要等人点击"确认"才能继续。这个等待时间完全不确定,可能是 30 秒,也可能是 20 分钟。我的停滞检测阈值是 30 秒,结果每次流程跑到人工节点都被WatchdogStall拦下。
排查下来发现这不算 bug,是我对"停滞"的定义太粗糙。修正方案是在执行上下文中增加一个waiting_for_human标志位,看门狗检查时,如果这个标志位为真,就暂停停滞检测,但全局 TTL 仍然有效。这个场景提醒我:看门狗不能对流程的内部状态一无所知,它需要知道"当前处于什么阶段",才能判断"长时间没有进展"到底是不是异常。
另一个反面问题是漏报。我最早只用max_steps=50,结果有一个死循环了 38 步的流程漏网了。那 38 步里,Agent 在"调用翻译工具→处理返回结果→发现结果不合格→重新调用翻译工具"之间反复横跳,每一步的哈希都不同(因为翻译结果确实每次都有微小变化),但行为模式完全一致。哈希策略对这种情况无能为力。
我的解决方案是在register_step()里加一个"动作序列模式检测":把每步的节点类型、工具名、LLM 调用标志压成一小串编码,比如LLM,TOOL,LLM,TOOL,然后在这个编码序列里做最长连续重复子串检测。如果检测到"某个模式串连续出现了 4 次以上",直接判定死循环。这个检测完全不依赖语义,纯粹看行为序列的结构,复杂度是 O(n),而且和哈希策略互补性很好。
6.2 取消调用不彻底:烧钱的重灾区
这个问题是生产环境里最阴损的一个:编排器已经判定超时并取消任务了,但账单还在涨。
原因是很多 HTTP 客户端的"取消"只是客户端不再等响应了,服务端那边还在照常处理请求、照常消耗 token。特别是调用某些推理服务时,流式响应已经开始,客户端一断开,服务端确实会尝试停止生成,但停止生效可能需要一两秒,而在长文本生成场景下,这一两秒就是几百个 token。
排查办法是在 telemetry 里记录每个 LLM 调用的request_id,超时取消后,故意等一下再调用厂商的查询接口,对比"我们记录的 usage"和"服务端实际的 usage",就能看出取消延迟到底吞了多少 token。实测下来,流式请求在客户端取消后,服务端平均还会多生成 300~800 个 token。这个量在小规模任务里无所谓,但如果你每天都跑几百个任务,累计起来也是个不小的数字。
应对措施有两个:一是所有 LLM 调用都走流式接口,这样取消时可以往连接里发一个 abort 帧,服务端能更快感知;二是把"单次 LLM 调用超时"的值设小一点(比如 30 秒),因为生成时间越短,取消延迟造成的浪费越小。这两点写进代码注释里,防止后人重新踩坑。
6.3 并发执行的竞态:状态记录不能"脏读"
流程图里如果有并行的分支(比如同时跑两个 Tool 节点然后汇集到一个 Aggregator),就会遇到竞态问题。两个分支同时结束,都想往hashes这个 deque 里追加指纹,如果没加锁,可能把前一个指纹覆盖掉,熔断器就会漏判。
我这里实际踩过一个大坑:Python 的deque.append()在 GIL 下是线程安全的,但我的执行引擎用的是 asyncio,不是线程。在 asyncio 的单线程模型里,await之间理论上是安全的,但如果在回调或任务组里并发地调register_step(),就会交叉。
解决方案是给熔断器的register_step()加一个asyncio.Lock,并且让"快照生成"和"指纹注册"在同一个临界区里完成。快照生成的函数本身也可能耗时(比如要序列化上下文),所以我在生成快照之前先拿锁,生成完、注册完再释放,保证一个步骤的指纹是原子提交的。另外,flow.snapshot()这个函数我也做了优化:它只抓取"结构化摘要"而不是全量上下文,所以生成速度非常快,锁的持有时间非常短,不会影响并发吞吐。
6.4 调试技巧:让每步执行留下"一行签名"
排查死循环类问题,最痛苦的是日志太长看不出规律。我后来养成了一个习惯:每一步执行完,往 telemetry 里写一行紧凑的"步骤签名"——包含步数、节点类型、调用的工具或模型名、响应内容的 MD5 前 8 位、这次调用的 token 数、当前累计成本。每跑完一个流程,cat 一下日志,十几行签名一目了然:
step=07 node=llm model=gpt-4o-mini resp_md5=3f2e9a1c tokens=1521 cost=$0.18 step=08 node=tool search query="memory leak" resp_md5=8b1c2d34 tokens=0 cost=$0.18 step=09 node=llm model=gpt-4o-mini resp_md5=8b1c2d34 tokens=0 cost=$0.18看到resp_md5连续重复而cost一直涨,基本不用再翻日志,死循环已经实锤了。这个签名格式后来被我做成了异步统计面板,跑完任务后自动汇总"重复率 Top10",一个流程里有哪些冗余步骤一眼就能看出来。
调试还有个小技巧:写一个LoopSimulator测试工具,它不需要真实 LLM,而是用一组预定义的"行为脚本"来模拟一个节点序列(正常的、轻微重复的、完全死循环的)。把看门狗和熔断器接到这个模拟器上,你可以在几秒钟内跑几十个场景,而不是每次都要等真实 API 调用花费几十分钟。这个模拟器我保留到了测试套件里,现在每次改代码都先跑一遍,确认安全机制不会把正常流程拦掉。
7. 收尾之前:这 13 篇做下来的一些体会
这个开源系列从第 1 篇的"能不能自己写一个 Agent 框架"写到现在,最大的收获不是代码本身,而是对"可靠性"这件事的理解。你可以在提示词里告诉 Agent 一万遍"不要重复执行",但你永远不能依赖它真的记住。真正靠得住的,只有运行时里那些确定性的、有点笨但绝对可靠的检查代码。
我的体会是:安全机制不能等出了问题再去补。我第一次跑通编排器时,就因为没有预装看门狗,眼睁睁看着一个任务烧掉 40 多美元。那次之后,我把安全组件从"可选优化"提到了"核心依赖"的位置。现在这套编排器的架构里,看门狗和熔断器是默认启用的,用户想关掉必须显式传参并且过一道警告。
最后分享一个小经验:给熔断器的循环分数衰减系数做两个版本,测试环境用 0.7 的衰减率(保守,容易触发熔断),生产环境用 0.3(相对宽松)。你会发现同一套代码在两个环境下的最优参数差很多,因为测试环境的任务短、token 少,就是专门用来找 bug 的;生产环境任务复杂,给安全机制留的余量必须更大。调参没有银弹,拿真实日志说话,比什么都强。
如果你也在做类似的 Agent 编排器,或者正在被死循环问题折磨,欢迎去看这个系列项目的源码。代码里现在不仅有这篇文章里讲的看门狗和熔断器,还有配套的模拟器、压测脚本和完整的测试用例。下个系列我打算把精力放在"多 Agent 协作时的全局状态一致性"上——对了,预算监控在那种场景下会更难做,因为它涉及多个 Agent 共享同一个钱包。到那会儿再来分享新的教训。