我们有个Agent,半夜跑一个工单处理任务,跑了接近两个小时,在调用内部订单接口之后进程直接卡死。重启之后我盯着日志反复看,越看越心虚:这个Agent既没有把上下文持久化,也没有做过检查点,任务恢复的代码是一行都没有,资源管控更是空白,用户只能含着泪重新提一遍任务。那次事故之后,我把Agent运行机制放到了和模型能力同等重要的位置。上下文、检查点、任务恢复、循环执行、资源管控,这五件事在23.3这个版本里被我们整体重构了一遍。下面是我拆完整个流程之后整理出来的完整版,适合正在做Agent框架、Agent平台,或者想把一个Agent Demo变成可运维服务的人。
1. 先理顺关系:五个机制不是五个孤立模块
很多人在接触这套东西的时候,下意识把上下文管理、检查点、任务恢复、循环执行、资源管控当成五个独立组件去设计。这个出发点很容易把系统拆碎。实际上,它们是同一条流水线上的五个切面,只有放在一起看才能理解各自的位置。
1.1 一次Agent运行的完整生命周期
拿我们23.3版本里一个标准Agent任务来举例,一次运行的生命周期是这样的:
- 任务接收:调度器拿到一个用户目标,比如"把上周发货失败的订单整理成报告并发送给客服组"。这个目标会被转换成一个任务对象,写入队列。
- 上下文装载:任务被某个Worker领取后,运行引擎先做上下文构建。系统指令、任务元信息、长期记忆里检索出的相关历史,一起被装进ContextManager。
- 循环决策:进入观察-思考-行动状态机。Agent读工具返回结果,让模型决定下一步动作,执行动作,然后把结果写回上下文。每轮结束后检查终止条件。
- 检查点落盘:在关键动作前后和每轮末尾,运行引擎把当前状态序列化到持久化存储。这不是可选配置,在23.3里是强制执行。
- 资源核算与回收:Token消耗、API调用次数、单步耗时、总耗时,每时每刻都被ResourceBudget记录。任务结束或失败后,这些数据进入执行报告,用于复盘和计费。
我见过不少人把注意力全放在模型选型和Prompt上,结果一跑线上就四处漏水。真正决定Agent能不能用的,不是它聪明不聪明,而是这个流程里的每一环在崩了之后能不能接回来。23.3版本做的最重要的事,就是把这五步变成一条可观测、可拦截、可恢复的流水线,而不是一段只能跑通的脚本。
1.2 23.3版本的核心变化:从"跑完算数"到"步步留痕"
早期版本的核心循环其实就是:
while agent.is_active(): next_action = agent.plan(context) result = execute(next_action) context.append(result)写Demo没问题,但生产环境里崩溃、超时、限流、上下文溢出是常态,这种写法一崩全丢。23.3版本把隐藏的运行时状态显性化成一个对象,我习惯叫它ExecutionRecord。每条记录里有任务ID与执行阶段、当前状态机的步骤编号、最新检查点ID、上下文版本指纹、资源预算使用情况。后面所有机制,包括检查点、恢复、循环控制、资源管控,都是围绕ExecutionRecord展开的。
可以把ExecutionRecord想象成飞机上的黑匣子外加仪表盘,既记录每一步,又实时显示油量和高度。Agent可以出问题,但有了这个对象,你能知道在哪一步出的问题、当时带了多少上下文、花掉了多少预算,而不是对着日志猜。
如果你正在从零搭Agent框架,我的建议是第一步不要急着写模型调用封装,先定义ExecutionRecord这个数据结构。它决定了你的系统能做多深的多层级回溯。当时我们为了偷懒先跑通再补,结果就是开头那次凌晨事故。后来重写23.3,就是把数据结构放到了第一优先级。
2. 上下文管理:Agent能记住什么,取决于你怎么喂"工作记忆"
先讲一个我见过很多次的错误:把上下文当聊天记录处理,每个历史回合都往messages里塞。窗口没爆的时候运行良好,一旦工具调用链变长,上下文很快撑满,然后模型开始"失忆",把几轮前的工具结果忘得干干净净。上下文管理不是简单的消息列表维护,它是一套预算、分层、压缩、指纹校验的组合策略。
2.1 上下文窗口与Token预算:不是把聊天记录全塞进去
上下文窗口是一个资源池,但输入和输出是共用的。以128K窗口为例,我通常只允许输入最多占75%,也就是96K,剩下的32K必须空着,留给接下来工具可能返回的新数据,以及模型这轮要生成的输出空间。如果输入塞到120K,模型输出会因为超限被截断,得到的答复是不完整的。对Agent来说,这往往不是少一句话,而是整个任务断在半途。
所以在ContextManager里我们做了两层检查。第一层是硬性预算,任务定义里设置max_input_tokens,防止一个步骤把预算吃光;第二层是软性预判,在调用模型之前预估当前消息数组的Token数,如果接近上限就先把上下文压缩一轮再调用,而不是直接报错。
class ContextManager: def __init__(self, window_size: int, input_budget_ratio: float = 0.75): self.window_size = window_size self.max_input_tokens = int(window_size * input_budget_ratio) self.system_instruction = SYSTEM_INSTRUCTION self.working_memory = [] self.history = [] def prepare_messages(self) -> list: msgs = [{"role": "system", "content": self.system_instruction}] msgs += self.history msgs.append({"role": "user", "content": self._render_working_memory()}) while estimate_tokens(msgs) > self.max_input_tokens: if len(self.history) > 0: self.history.pop(0) else: break if estimate_tokens(msgs) > self.max_input_tokens: self.history = compress_history(self.history) return msgs这里的estimate_tokens在生产环境里不要真的每轮跑完整tokenizer,太慢。用一个按字符数和语言加权估算的快速函数就够,误差控制在10%以内即可。
2.2 记忆分层:系统指令、工作记忆、长期记忆
上下文不是一条messages数组,它至少应该分成三层。系统指令层放角色设定、任务边界、工具使用规范,优先级最高,不允许被裁掉。工作记忆层放当前任务的关键信息,比如任务ID、当前目标、已经确认的中间结论、下一步计划,每轮由引擎动态渲染,放进一条user消息里。长期记忆层放过去任务的结论、知识库检索片段、用户长期偏好,按需加载,不全量塞入。
为什么必须分层?模型注意力不是均匀的,越靠后的内容越容易被记住,但越靠后被塞入的无关内容也越会干扰决策。系统指令放最前面,保持稳定;工作记忆放最后,因为它是模型马上要用的;长期记忆穿插中间,作为参考。这个顺序不是随便定的,是我们在多轮实验后观察到的实际效果:工作记忆放在最后时,Agent对目标的保持明显更稳。
2.3 超长上下文的压缩策略
上下文还是会超长,这时就要看底层策略。我常用的有三种:滑窗截断会丢最久远的历史,简单粗暴但会丢掉工具结果的依赖关系,比如第3轮查询到的订单号在第10轮还要用,丢了整个规划就断了;摘要压缩是对一段历史做摘要替换,能相对保留依赖关系,但摘要会损失细节,而且生成摘要本身也要消耗Token和时间;向量检索把历史写入外部向量库,按相关性取回,适合跨会话长期记忆,但因为它取的是Top-K,可能漏掉当时看起来不相关后来才关键的细节。
23.3版本的实际做法是组合使用:对话早期历史优先做摘要压缩,保留结论和关键参数;同一次任务里最近的N轮保留原文用于滑窗;长期记忆统一放向量库按需检索。另外有个容易被忽略的点:每次压缩后要把原始数据存到检查点里,这样恢复任务时如果需要细节,还能去检查点翻原文,而不是彻底丢干净。
2.4 上下文指纹:恢复时判断上下文是否还能用
这一节很关键但很容易被忽略。上下文快照在检查点里被存下来了,可恢复任务时系统指令或任务参数可能已经改了。如果直接照着旧快照继续跑,模型用的规则是上一版,结果自然不对。所以我在ContextManager里维护了一个指纹,对系统指令、任务目标、工具定义列表做哈希。恢复时先比对指纹,不一致就重新构建工作记忆,只沿用历史消息,而不是整份上下文原样恢复。
def build_fingerprint(system_instruction, task_meta, tools) -> str: raw = json.dumps({ "system": system_instruction, "task": task_meta, "tools": tools }, sort_keys=True) return hashlib.sha256(raw.encode()).hexdigest()[:16]这个指纹要写进每个检查点。恢复逻辑里指纹不一致时,宁可多花一轮重建上下文,也不能带着旧规则硬跑,否则后续所有决策都建立在错误的前提上。
3. 检查点与任务恢复:Agent崩溃后的"续命",靠每一步留痕
回到开头那次凌晨事故,如果当时我们有检查点机制,崩溃之后用户是不需要重新发起任务的。任务恢复不是说把Agent重跑一遍,而是从它死掉的那一步继续往下走,这需要检查点设计得足够细。
3.1 检查点里到底存什么
很多人以为检查点就是存messages数组。我一开始也这么想,后来发现远远不够。一个完整的检查点应该包含任务头,包括任务ID、检查点ID、时间戳、Agent版本号;执行位置,就是当前处于状态机的哪一个阶段,已经完成了哪些工具调用;上下文快照,包括当前messages的压缩版、工作记忆、历史摘要、上下文指纹;中间结果缓存,也就是已经成功的工具调用返回值,这部分是幂等恢复的关键;资源计数,包括已消耗的Token、调用次数、剩余预算;还有下一步意图,比如模型最近一次输出的行动计划,如果动作已经写入计划但还没执行,恢复时可以直接执行。
这个结构决定了恢复能做到多细。只存消息,最多让你接着聊,但状态、预算、中间结果全丢了,等于还是半重跑。
3.2 写入策略:每轮都写 vs 关键节点写
全量检查点很贵,尤其是上下文长的时候。我们试过每轮都全量写,任务跑100轮就写100次大快照,IO明显拖慢任务。后来改成两级策略。轻量检查点每轮循环结束写一次,只记录执行位置、步数、资源计数、最后操作的指纹,体积小写入快。重量检查点在调用外部工具之前和拿到工具结果之后各写一次,包含完整上下文快照和中间结果缓存。因为工具调用是副作用集中发生的地方,这个节点最需要被恢复。
此外,任务开始和完成时各写一个归档点。任务完成后归档点数据直接进入执行报告,后续复盘可以直接翻。
def run_checkpointed_agent(record): for step in state_machine: write_light_checkpoint(record, step) if step.has_tool_call: write_heavy_checkpoint(record, "before_tool") result = execute_tool(step.tool_call, idempotency_id=step.invocation_id) if step.has_tool_call: write_heavy_checkpoint(record, "after_tool", result)这里有个性能经验值:重量检查点占两个任务总时长的比例别超过5%。超过就说明快照体积太大了,需要把上下文快照改成摘要加引用,而不是完整复制。
3.3 崩溃恢复的任务状态机
状态机比想象中要细。我们在23.3里给每个任务维护了这些状态:
| 状态 | 含义 |
|---|---|
| pending | 已入队,未开始 |
| running | 正在执行 |
| paused | 因资源不足或人工干预暂停 |
| completed | 正常交付 |
| failed | 无法恢复的失败 |
崩溃发生时,任务停留在running。重启后恢复器的逻辑是:读取最新检查点,把状态置为recovering,然后根据检查点里的执行位置重新进入循环。已经标记completed的步骤直接跳过,failed的步骤重置后重跑。每恢复一步,先写recovering状态再写步骤状态,防止再次崩溃陷入重复恢复。
3.4 恢复时要避开的坑
第一个坑是上下文过期。检查点里存的可能是三个小时前的订单状态,外部系统早就变了。恢复时对只读查询类步骤,建议先重跑刷新,再往下走。我们为此在工具定义里加了refreshable标记。
第二个坑是重复执行副作用。恢复后重跑某一步,如果这一步是给用户发邮件或者扣减库存,就会造成灾难。解决办法是给每个工具调用分配一个invocation_id,调用前先查执行记录的中间结果缓存,命中就直接返回缓存结果。幂等键必须由任务ID加步骤号加动作名拼接生成,并且持久化在检查点里,不能是进程内存里的自增ID,否则进程重启后去重就失效了。
第三个坑是检查点写一半损坏。写入时先写临时文件,再原子rename,绝对不能直接覆盖主文件。恢复时发现检查点损坏,要能自动退到上一个可用检查点,而不是直接抛错。
4. 循环执行与资源管控:给Agent装上油门、刹车和仪表盘
Agent的价值在于自主,但自主不能无边界。循环执行是动力系统,资源管控是刹车系统,两者必须同时存在,缺一个,另一个就会失控。
4.1 Agent循环的天然形态:观察-思考-行动
大多数Agent框架的循环都是三个状态:observe把工具结果、环境状态、系统消息收集齐;think让模型根据当前上下文决定下一步动作;act执行一个工具调用或直接输出。我把这个循环看成一个状态机而不只是while循环,是因为它必须能在任意一个状态被打断。资源不够时能暂停,超时后能恢复,人工可以随时介入。状态机天然能把这种打断变成合法事件,而不是异常。
4.2 循环终止条件:不能只靠模型自觉
"让Agent自己判断任务完成"这种说法太空了。模型可以宣称它完成了,但真实交付需要一个可验证的终止条件。我们在LoopController里做了四层检查。第一层是目标判定,工作记忆中维护goal_met字段,只有满足任务定义里的交付标准才允许为true。第二层是步数上限,每个任务创建时会给max_steps,默认30步,超了强制终止并归档。第三层是无进展检测,连续N轮生成的行动计划没有实质变化,或者输出内容相似度超过阈值,判定为空转,强制终止。第四层是人工接管,提供暂停和停止接口。
class LoopController: def should_stop(self, record) -> bool: if record.goal_met: return True if record.step_count >= record.max_steps: return True if record.no_progress_count >= 3: return True if self.human_interrupt_raised(record.task_id): return True return False无进展检测的实际实现是记录每轮think的action_signature,也就是工具名加关键参数哈希。如果连续3轮signature完全一样,且工具返回没有新信息,就判定空转。这个检测在长任务里尤其重要,因为模型经常会在同一组局部信息上反复绕圈。
4.3 资源管控的四张配额卡
资源管控我习惯拆成四张卡。第一张Token卡,管总Token预算和分阶段预算,比如整个任务上限500K Token,用到80%就进入降级模式。第二张调用卡,管外部API的调用次数和QPS,防止一个Agent循环把订单系统打到限流。第三张并发卡,管同时运行的任务数和单个Agent内部的工具并行度。第四张时间卡,管任务总挂钟时间和单步超时时间,单步超时比总超时更容易被抓住排查。
每张卡都要有独立的超额处理策略。Token快用完可以压缩继续,调用次数快用完就停掉非核心步骤,并发不够就排队,时间超了就冻结任务隔天再恢复。它们不是都用"直接杀掉"这种粗暴手段。
4.4 超额之后的优雅降级:不是硬杀,而是缩小目标继续做
早期的实现是配额用完了任务直接failed,用户损失很大。后来我们改成降级链路。第一步压缩上下文,把历史摘要再压缩,释放Token空间,比如把三次工具调用的详细结果合并成一行结论。第二步缩小目标,把当前任务拆成子目标,先把最重要的交付做掉,次要的标记为spillover,等配额恢复再跑。第三步切换模型,低风险步骤切换到小模型省Token,注意切模型后指纹会变,要写在检查点里。第四步暂停,以上都不够就暂停任务保存检查点,等资源池有余额时恢复。
降级不是失败,是让任务在受控范围内以更低的成本继续。这也是我坚持把资源管控和循环执行放在一起讲的原因:两者分别管能跑多久和跑到何时停,配合起来才是一套完整的执行策略。
5. 生产环境里最容易翻车的细节与23.3版实测经验
把机制写完只是第一步,真实跑生产之后会遇到一堆文档里不会写的问题。这一章聊的是23.3版本跑线上任务以来攒下的实测经验。
5.1 检查点写太频繁会拖垮性能
最开始的版本里我们追求每步都全量持久化,结果任务越跑越慢,最后发现IO成了瓶颈。后来的策略是:轻量检查点每轮都写,重量检查点只在工具调用前后和任务结束写。如果用的是远端存储,建议把轻量检查点写到本地缓存,批量异步上传,否则网络延迟会打进任务延迟里。
另一个优化是快照体积。重量检查点里不需要存完整的历史原文,存摘要加原始消息的引用地址就够了。需要原文时再按需加载,这个改动让重量检查点体积直接缩小到原来的三分之一。
5.2 恢复任务时上下文过期,比崩溃本身更隐蔽
上下文过期问题在测试环境很难暴露,因为测试数据不会变。生产里恢复一个三小时前的检查点,再去查订单状态,可能已经改价、取消、甚至退款了。所以我们给每个工具定义里加了一个refetch策略:
| 工具类型 | 恢复策略 |
|---|---|
| 只读查询 | 恢复时重新执行,更新上下文 |
| 副作用写入 | 用invocation_id去重,不重复执行 |
| 依赖外部状态的操作 | 先做前置校验再执行 |
恢复逻辑不是死板地从断点续跑,而是先做一轮环境同步,把快照里可能过期的外部状态刷新一遍,再决定从哪一步继续。这个同步步骤在23.3里叫resync,是任务恢复的必经环节。
5.3 幂等性:恢复重跑不能让副作用翻倍
幂等设计里最容易被忽略的是,幂等键要随任务走,而不是随进程走。如果幂等键是内存里的自增ID,恢复后进程重启,ID从0开始,同一个操作就会变成两个不同ID,去重失效。幂等键必须由任务ID加步骤号加动作名拼接生成,并且持久化在检查点的中间结果缓存里。恢复时,只要中间结果缓存里有对应invocation_id的成功记录,就直接返回缓存结果,不真正调用外部系统。
有些操作天然做不到幂等,比如"追加一条备注"和"发送一次通知"。这种情况要么改成条件写入,先查后写;要么在调用前后都打重量级检查点,并申请人工确认再重放。不要指望模型自己能识别是不是重复操作,模型对它自己刚说过的话是没有记忆的。
5.4 资源管控不能一刀切,按任务等级差分
把所有任务放在同一个资源池里,结果就是几十个批量任务把Token和调用次数耗尽,重要的实时任务反而被卡住。23.3里我们按任务等级分了资源池:
| 任务等级 | Token预算 | 调用/分钟 | 并发 | 优先级 |
|---|---|---|---|---|
| 核心任务 | 高 | 高 | 低 | 插队 |
| 普通任务 | 中 | 中 | 中 | 正常 |
| 批量任务 | 低 | 低 | 高 | 排队 |
批量任务做长跑,但给最少资源,可以慢慢跑;核心任务数量少,但必须保证随时有预算。这个差分策略上线之后,线上超时投诉明显降了下来。
5.5 可观测性:把运行机制暴露成事件流
最后一条经验:运行机制不应该是黑盒。每一个Agent任务在执行过程中都会生成结构化事件流,事件包括上下文构建完成、检查点写入、步骤执行、工具调用完成、预算告警、降级触发、恢复完成。这些事件统一打到日志中心,用任务ID做关联。排查问题时,只要搜任务ID,就能看到它每一步在哪里、花了多少Token、为什么被降级。
这一条在早期版本完全没做,导致每次出问题都在翻print和猜状态。做了事件流之后,大部分线上问题能在半小时内定位到具体步骤,而不是从崩溃堆栈里逆向推理。
最后再分享一个小技巧:在每个重量检查点里额外存一个summary字段,内容是当前任务已经完成了什么、下一步打算做什么的模型摘要。恢复任务时,先用这个summary快速恢复工作记忆,再按需加载详细历史。这个小改动让恢复后的首个思考轮质量明显提升,模型不用从一份巨大的旧上下文里翻找线索,直接顺着summary往下规划就行。