笃行
首页
个人 & 心法
互联网/硬件后台
游戏基础架构
UE 引擎
游戏业务
AI / 大模型
数据结构与算法
机器学习数学
通用基础
GitHub
首页
个人 & 心法
互联网/硬件后台
游戏基础架构
UE 引擎
游戏业务
AI / 大模型
数据结构与算法
机器学习数学
通用基础
GitHub
  • AI / 大模型

    • AI / 大模型
    • 大模型核心原理
    • 推理与微调优化
    • RAG 检索增强生成
    • RAG 上下文剪枝实战(Listwise Pruning 复现)
    • RAG 数据清理
    • RAG 存量数据清理
    • Agent 开发
    • Agent 运行时深水区
    • Multi-Agent 协作
    • Agent 评测与线上运营
    • AI 与游戏研发周期
    • 互动影游与长视频创作 Agent
    • LLM 应用安全
    • LLM 评测方法论
    • LLM 成本与延迟
    • 微调策略
    • AI 研发工程化

Agent 运行时深水区:一次请求在框架里的一生

调度状态机 · 分层记忆读写时机 · 工具沙箱与权限 · 并发/预算/幂等

🧠 一句话记忆锚点

Agent 运行时 = 一台带预算的状态机:状态显式(PLANNING/CALLING_TOOL/OBSERVING/REFLECTING/DONE/ABORTED)才可观测可恢复;四条终止线(终态 / 轮数 / 预算 / 无进展)里最难的是"无进展"——靠调用指纹重复 + 连续 N 轮零新事实判定;记忆三层各有写入触发与读取时机,pin 的关键事实不参与滑动;工具侧靠 Schema 强校验 + 三级危险分级 + 进程隔离 + 凭据只给句柄;并发只放只读工具,副作用工具串行且带幂等键;预算耗尽要优雅收尾返回部分结果,不是抛异常。

名词速查

首次阅读可跳过本表,直接看「场景问题」,遇到生词再回查。

本篇是Agent 运行时这一组名词的归属页,全部结论以「单机单 Agent」为规模前提。这一组词是后台工程师最容易上手的一组——状态机、水位线、沙箱、连接池、熔断、幂等键,全是你写过的东西,只是被治理的对象换成了模型调用。

名词后台类比在系统里干什么类比失效边界
调度状态机订单状态机 / 工作流引擎——状态显式落库才可观测、可恢复把 Agent 的每一轮显式建模为状态(PLANNING / CALLING_TOOL / OBSERVING / REFLECTING / DONE / ABORTED),使中断可恢复、异常可定位订单的状态转移由业务规则决定,合法转移集合可穷举;这里下一个状态由模型的输出决定,你只能约束"允许哪些转移",不能预知它会走哪条。所以状态机的价值不在控制流程,而在让不可预知的流程变得可观测
四条终止线超时 + 重试上限 + 配额 + 死循环检测的组合闸门四个必须同时存在的停止条件:到达终态 / 轮数上限 / 预算耗尽 / 无进展判定常规超时和重试上限就能覆盖大多数服务;Agent 多了一条没有对应物的"无进展"——它不超时、不报错、每步都"合理",但在原地打转。这条线必须专门实现(调用指纹重复 + 连续 N 轮零新事实),前三条拦不住它
无进展判定(No-Progress)死循环检测——但不能只比对请求是否完全相同判断 Agent 是否在原地转圈:调用指纹重复、或连续 N 轮没有产出新事实常见的死循环检测比对完整请求即可;这里的循环每次参数都略有不同(差一个时间戳后缀就绕过了去重),所以必须做语义级指纹(工具名 + 关键参数归一化)并叠加"零新事实"判据。仅靠精确去重必然漏判
分层记忆(Layered Memory)
中间层称工作记忆,亦作 中期记忆
三级缓存架构:本地内存(短期原文)→ 进程内缓存(工作记忆:摘要 + 关键事实)→ 远端存储(长期向量库)分三层存对话与事实,各层有独立的写入触发与读取时机,让有限的窗口装下长会话三级缓存的每一层存的是同一份数据的副本,miss 了向下层取即可,数据完整;分层记忆的三层存的是不同保真度的加工产物——摘要层是有损压缩过的,原文已经丢了,向下层"取回"拿不到原始细节。信息一旦被摘要就不可逆
压缩水位(Compaction Watermark)
亦作 语义压缩
磁盘/队列的高低水位线——过线触发清理,而不是满了才处理上下文占用达到某个比例(如窗口的 70%)时触发压缩,而非等到塞满报错磁盘清理是删掉确定无用的数据,删完容量精确回收;这里压缩是把原文换成摘要——省下多少 token 事前无法精确预知(取决于模型怎么写摘要),而且必然损失信息。所以水位不能设得太靠后,留不出反悔余地
压缩阶段选择选择在哪个环节做归档——刚写入时、读取前、还是空闲时决定在一轮的哪个位置执行压缩:调用模型前、工具返回后、还是反思阶段。位置决定了压缩看到多少信息归档时机主要影响性能,数据内容不变;压缩时机直接改变压缩质量——工具返回后立刻压,可能把下一步才用得上的细节压掉了;等到反思阶段再压,这一轮已经付过全额 token 了。这是质量与成本的对赌,不是纯性能优化
pin(关键事实固定)缓存的常驻不淘汰项 / 白名单——标记为永不清理把关键事实(用户 ID、任务目标、已确认的约束)标记为不参与滑动窗口淘汰、不被压缩缓存白名单是精确键匹配,命中即保留;pin 的对象是自然语言事实,靠模型或规则抽取出来——抽漏了就丢,抽多了会持续占用窗口。且 pin 的内容会永久占预算,pin 得越多留给正文的窗口越小
工具沙箱(Tool Sandbox)容器隔离 / seccomp——限制被执行代码能碰到的资源用独立进程或容器执行工具(尤其 CodeAct 生成的代码),限制文件系统、网络、系统调用容器隔离的是你自己写的、可信但可能有 bug 的代码;这里隔离的是模型现场生成的、可能被注入操纵的代码——威胁模型是"对抗性"而不是"意外性"的,所以逃逸的动机和手法完全不同,默认配置远远不够,见 LLM 应用安全
权限分级(三级危险分级)接口的读/写/危险操作分级 + 审批流把工具分为只读 / 有副作用可回滚 / 不可逆三级,各级走不同的确认与审计路径业务接口的权限由调用方身份决定,鉴权是确定的;这里调用方永远是同一个 Agent,判断"这次该不该允许"只能靠工具本身的危险等级 + 人工闸门。你无法靠身份鉴权拦住它,因为它就是那个被授权的身份
凭据句柄(Credential Handle)只传连接池的句柄,不把密码给业务代码工具需要的密钥/token 只以不透明句柄形式暴露给模型,实际凭据由运行时在执行时注入业务代码不会主动泄露拿到的密码;模型会把上下文里的任何东西写进输出——密钥一旦进过上下文,就可能被复述到日志、返回给用户、或被注入攻击套出来。所以不是"最好别给",是绝对不能进上下文
并发工具调用只读请求可以并行、写请求必须串行——经典的读写分离约束同一轮里的多个工具调用并行执行以省时间,但只对只读工具开放,有副作用的工具串行数据库的并发控制有事务与锁保证正确性;这里的并发没有事务——两个副作用工具并行执行后若中途失败,没有统一回滚。所以约束不是"加锁"而是"根本不并行"
预算熔断(Budget Circuit Breaker)熔断器 + 配额限制——超阈值就断开,保护下游给单次任务设 token / 调用次数 / 时长上限,耗尽时优雅收尾返回部分结果,而不是抛异常常规熔断的正确行为是快速失败、拒绝请求;这里失败不是最优解——Agent 跑了 40 轮已经产出了大量有价值的中间结果,直接抛异常等于把已花的钱全扔掉。所以它必须"降级收尾",这与熔断的常规语义相反
幂等键(Idempotency Key)就是你写过的那个幂等键——同一操作重复提交只生效一次给有副作用的工具调用带上幂等键,让重试与恢复不会造成重复扣款、重复发送语义基本一致,这是本表里最接近后台原物的一个概念。差别在于键从哪来:业务侧幂等键由调用方生成且稳定;这里若让模型生成键,它重试时可能换一个键(那就失去了幂等性)——键必须由运行时按"工具名 + 归一化参数"确定性地算出来,不能交给模型

同名异义:context 与 memory 在推理侧指的是别的东西

这是全域最容易埋雷的一处。把两侧混为一谈,就会推出"把上下文窗口扩大就等于 Agent 记得更多"这个错误结论——不对,窗口是物理上限,记住什么是本篇讲的装配策略。

名词在Agent 运行时(本篇)指在推理服务指去哪读另一侧
context本轮装配进 prompt 的记忆检索结果——是一次主动的取舍决策:这一轮让模型看到哪些历史、哪些事实、哪些工具输出注意力可见的 token 窗口——模型一次前向能看到的 token 物理上限,受 O(n²) 算力与显存约束推理与微调优化 · 名词速查
memory分层记忆——短期窗口 / 工作记忆(摘要+pin)/ 长期向量库三层的读写策略,与物理内存无关GPU 显存——权重 + KV Cache + 激活占用的物理内存,是容量规划的硬约束推理与微调优化 · 名词速查

一句话记住:窗口是能力上限,装配是本轮选择;显存是物理资源,分层记忆是读写策略。窗口给你更大的盘子,但盘子里放什么,仍然是本篇这套装配与压缩策略决定的。

本篇引用的其他上下文名词

名词在本篇里的角色完整解释去哪读
ReAct / Plan-and-Execute / CodeAct / Function Calling / MCP本篇实现的那些范式与协议Agent 开发 · 名词速查
协作拓扑 / 通信机制 / MAS 特有失效多 Agent 规模下的延伸(本篇只交待压缩窗口归属的接口)Multi-Agent 协作 · 名词速查
文本 Embedding / 向量检索 / Rerank长期记忆层本质上就是一次 RAG 调用RAG · 名词速查
TTFT / TPOT / token 经济 / 预算护栏口径预算熔断的计价与延迟依据成本与延迟 · 名词速查
提示注入 / 越狱 / 权限最小化沙箱与权限分级要防的攻击LLM 应用安全 · 名词速查
span 树 / 轨迹归因状态机可观测性的下游消费者Agent 评测与线上运营 · 名词速查

场景问题

线上一个巡检 Agent 半夜跑了 4 小时、烧掉 1800 次模型调用,最后返回一句「我需要更多信息」。翻日志才看清它的轨迹:第 7 轮拿到一个空的查询结果,第 8 轮换了个参数又空,然后从第 9 轮开始,它在「查日志 → 发现没数据 → 决定再查一次日志」之间来回跳了 190 多次——每次参数只差一个时间戳后缀,所以任何「同调用去重」都没拦住它。

它没有崩、没报错、每一步看起来都「合理」。坏的是运行时:没有显式状态、没有进展判定、没有预算硬闸。

Agent 开发 那篇讲清了 Agent 该有哪六件套;这篇只回答一个问题——这六件套在代码里到底长什么样。全篇沿一次请求的生命周期单调下钻:装配上下文 → 进状态机 → 调工具 → 回填观察 → 判定终止。

阅读边界

本篇是 Agent 运行时实现的单一事实源,全篇结论以「单机单 Agent」为规模前提。范式对照(ReAct / Plan-and-Execute)、框架选型、Prompt 杠杆见 Agent 开发;评测指标与线上归因见 Agent 评测与线上运营;向量召回的检索链路见 RAG;token 计价与延迟拆解见 成本与延迟。

多 Agent 特有的部分——协作拓扑、通信机制、结果不一致的裁决协议、MAS 五种特有失效(死锁 / 责任扩散 / 上下文分裂 / 级联幻觉 / token O(N²))——全部归 Multi-Agent 系统,本篇只在「压缩窗口归属」一处交待与它的接口。

实现方案

一、调度循环:把隐式 while 改写成显式状态机

打个比方:这一步就是把订单流程从"一堆 if-else 埋在一个大函数里"改成显式状态机落库——CREATED / PAID / SHIPPED / DONE / CANCELLED。改完之后好处是一样的:出问题能查到卡在哪一步、进程重启能从落盘状态接着跑、想单测某个转移不必把整条流程跑一遍。类比失效边界:订单的下一个状态由业务规则决定,合法转移能穷举、能画死;这里下一个状态由模型的输出决定——你只能声明"允许哪些转移",无法预知它会走哪条。所以状态机在这里的价值不是"控制流程",而是让本来不可预知的流程变得可观测、可恢复。

绝大多数 Agent 原型的循环长这样:

while True:
    resp = llm(messages)
    if resp.tool_calls:
        for tc in resp.tool_calls:
            messages.append(run_tool(tc))
    else:
        return resp.text

能跑,但不可观测、不可恢复、不可测试:出问题只能看整段日志猜它在干什么;进程重启从头再来;想单测「预算耗尽时的行为」得把整个循环跑一遍。

生产形态是把状态提出来:

状态含义合法后继
PLANNING已装配上下文,等模型决策CALLING_TOOL / DONE / ABORTED
CALLING_TOOL模型要求调工具,执行中OBSERVING / ABORTED
OBSERVING工具返回已回填,做记账与截断REFLECTING / ABORTED
REFLECTING判定进展 / 预算 / 是否继续PLANNING / DONE / ABORTED
DONE模型给出终态答案—
ABORTED被闸门中止(预算 / 轮数 / 无进展 / 护栏)—

四条终止线,缺一条就会出现开篇那种事故:

  1. 模型给出终态 —— 正常出口。
  2. 最大轮数 —— 硬上限,兜底。
  3. 预算耗尽 —— token / 时长 / 费用任一触顶。
  4. 无进展 —— 最难的一条,单独说。

「无进展」怎么变成可判定的条件

「模型在原地打转」是个体感描述,落到代码需要两个可算的信号:

  • 调用指纹重复:fingerprint = hash(tool_name + canonical_json(args))。注意开篇事故的教训——参数里的时间戳、trace_id、随机 nonce 必须先剔除再算指纹,否则每次指纹都不同,去重形同虚设。指纹进一个滑动集合,命中就计一次重复;连续 3 次同指纹直接中止。
  • 连续 N 轮零新事实:每轮结束时抽取本轮新增的事实键集合(见下节的 extract_facts),与累计集合求差。差集为空即「零新事实」,连续 N 轮(经验值 3)为零则中止。

两个信号是互补的:前者拦「同一个动作反复做」,后者拦「换着花样做但什么都没查到」。

规模锚点:连续 3 次同指纹 与 连续 3 轮零新事实 这组阈值适用于单机单 Agent、任务预期步数 5~30 的场景。预期上百步的长任务(如全仓库重构)要放宽到 5~8,否则会误杀正常的「多次探索同一目录」;多 Agent 协作时指纹要带 agent_id,否则两个 agent 查同一份数据会被误判成打转。

checkpoint 的最小字段集

每次进入 REFLECTING 落一次盘,字段只要五样:

@dataclass
class Checkpoint:
    task_id: str
    turn: int
    state: str                  # 状态机当前状态
    current_focus: str          # 模型自述的"我正在做什么",一句话
    facts: dict[str, str]       # 累计关键事实(pin 住的那部分)
    budget_used: BudgetUsage    # token / 秒 / 费用 三个累计值

为什么不存完整 messages:存了也没用——恢复时上下文早已超窗,必须重新装配。current_focus + facts 是重建上下文的最小充分集合,这也是 AI 巡检 崩溃恢复能在两轮内接上的原因。

这一节记住三句话

  1. 隐式 while 的问题不是"跑不通",是不可观测、不可恢复、不可测试。把状态提成显式枚举 + 一张「合法后继」表,这三样才同时成立——状态机在这里是主路控制结构,不是旁路日志。
  2. 四条终止线缺一不可:模型给出终态(正常出口)、最大轮数、预算耗尽(token / 时长 / 费用任一触顶)、无进展。前三条是硬上限,写一行就有;第四条最难,也最容易被省掉。
  3. 「无进展」必须落成两个可算信号:调用指纹重复(算指纹前 MUST 剔除时间戳 / trace_id / 随机 nonce,否则去重形同虚设)+ 连续 N 轮零新事实。恢复用的 checkpoint 只需五个字段,不存完整 messages——恢复时上下文早已超窗,必须重新装配。

二、分层记忆:写入触发、读取时机与三种失效

打个比方:三层记忆就是你熟悉的三级缓存——本地内存放最近原文(快、但装不多)、进程内缓存放摘要与关键事实、远端存储放长期向量库。取数时从近到远,写入时各层有各自的时机。类比失效边界:三级缓存的每层存的是同一份数据的副本,miss 了向下层取即可、数据完整;这三层存的是不同保真度的加工产物——摘要层是有损压缩过的,原文已经丢了,向下层"取回"拿不到原始细节。信息一旦被摘要就不可逆,所以这里的设计压力不在命中率,而在"压什么、什么时候压、哪些绝不能压"。

Agent 开发 给了三层结构的结论。这里补的是每层什么时候写、什么时候读——这才是实现时真正卡人的地方。

层写入触发读取时机存放位置
短期(最近 N 轮原文)每轮无条件追加每轮全量注入内存 messages 列表
中期(摘要 + 关键事实)token 水位越线 或 每 K 轮(取先到者)每轮全量注入 system 段checkpoint 的 facts + 一段摘要文本
长期(向量库)任务结束时整条轨迹入库;中途仅在压缩时把被丢弃的原文入库按需召回,不是每轮向量库(一次 RAG 调用)

三个容易写错的点:

1. 压缩水位线不能贴着上限设。 设 ctx_limit 为模型上下文上限,压缩触发水位 W,则必须留出「一轮的呼吸空间」:

W ≤ ctx_limit − (max_tool_output + expected_model_output + safety_margin)

代一遍数字(ctx_limit = 128k):

按平均值算:128k − (工具返回 8k + 模型输出 4k + 余量 5k) = 111k   → 水位 0.87
按 p99 算:  128k − (工具返回 30k + 模型输出 4k + 余量 5k) = 89k   → 水位 0.70
再留一次"压完仍然超"的重试余地                              ≈ 77k   → 水位 0.60

所以工程上意味着什么:水位这个数必须按 max_tool_output 的 p99 算,不能按平均值算——工具返回是重尾分布,平均 8k 的接口偶尔吐 30k 是常态,而爆窗只需要发生一次。经验取 W ≈ 0.6 × ctx_limit 就是这么倒推出来的,不是拍脑袋的保守。贴到 0.9 的后果是:压缩刚做完,下一个工具返回一个大 JSON 就直接爆窗,而此时你已经没有可压缩的东西了(刚压过)。

2. 长期记忆不能每轮召回。 每轮都做一次向量检索,等于每轮往上下文里塞 3~5 段历史文本——既烧 token 又引入噪声,还会触发下面第三种失效。只在两个时机召回:任务启动时(找相似历史任务)、模型显式请求时(给它一个 recall(query) 工具)。

3. pin 的实现不是「把重要的话复制一遍」。 它是一个独立于滑动窗口的存储区:

FACT_PROMPT = """列出到目前为止必须记住的关键事实,key: value 一行一条。
只列约束条件、已确认的结论、用户的硬性要求。不要列过程和猜测。"""

def compress(ctx: Context) -> Context:
    old, recent = ctx.messages[:-KEEP_TURNS], ctx.messages[-KEEP_TURNS:]
    summary = llm(SUMMARY_PROMPT, old).text
    new_facts = parse_kv(llm(FACT_PROMPT, old).text)

    ctx.facts.update(new_facts)          # 累积,不覆盖
    archive_to_vector_db(ctx.task_id, old)   # 被丢弃的原文进长期
    ctx.messages = recent                # 只有这里在"滑动"
    ctx.summary = summary
    return ctx

def assemble(ctx: Context) -> list[Message]:
    # facts 每轮重新注入 system 段 —— 它永远不在 messages 里,所以永远滑不走
    system = f"{ctx.role_prompt}\n\n<facts>\n{fmt_kv(ctx.facts)}\n</facts>\n\n<summary>{ctx.summary}</summary>"
    return [Message("system", system), *ctx.messages]

关键在 assemble:facts 从来不进 messages,所以滑动窗口物理上碰不到它。如果实现成「把关键事实作为一条 message 插在前面」,它迟早会被滑出去。

三种失效:怎么发现、怎么兜底

失效表现检测手段兜底
摘要漂移多轮压缩后,硬约束("用 Python 3.10")从摘要里消失,模型开始违反用户原始需求单独存一份,每次压缩后跑一次断言:约束键是否仍在 facts 中硬约束不走摘要通道——首轮就抽进 facts,摘要只承载过程
滑窗误删指令早期 user 明确说的"不要动数据库"被踢出窗口后被违反同上:把「禁止类指令」标记为 sticky factssticky facts 永驻 system 段,且在工具调用前做一次显式校验
向量误召召回一段语义相近但结论相反的历史("上次这个报警是误报",而这次是真的),模型直接采纳召回结果带时间戳与置信度;对召回内容做一次「是否与当前观察冲突」的显式判定召回内容以 <reference> 包裹并标注"历史参考,非当前事实",禁止直接作为结论

三种失效有个共同点:都不会报错。它们表现为「Agent 变笨了」,所以必须靠上面这些主动断言去发现,不能等异常。

压缩的时机与阶段:在一轮的哪一步压

上面的水位线回答了「什么时候压」(token 越线即压),这里回答「在哪一步压」——同一次压缩放在一轮的不同位置,后果完全不同。一轮内有三个候选点:

候选压缩点能不能防爆窗后果判据
① 装配前(把 messages 拼成请求、准备发给模型时)能,且只有它能此时本轮要发的全部内容都已确定,token 总量算得准全窗压缩固定放这里——它是最后一个还能改的时机
② 工具返回后立即部分能第一时间掐掉一个大 JSON,但此时不知道下一轮还要塞什么,全窗压缩容易压过头(把还用得上的原文提前丢了)只做单条截断:对超阈值的单个工具输出截断 + 存全文供惰性回读,不做全窗压缩
③ 模型输出后(轮末收尾)不能本轮请求已经发出去了,爆窗已经发生——在这里压缩只是给下一轮做准备,对本轮无救只做异步归档:把压缩时被丢弃的原文写入向量库,不承担防爆窗职责

一句话结论:全窗压缩固定在装配前;工具返回后只做单条截断;轮末只做异步归档。

三者的分工不能互换,尤其②不能替代①:工具返回后压缩看起来更"及时",但它缺少一个关键信息——本轮模型还会不会再要求调工具、要求几次。缺了这个信息就无法算准 token 预算,压多了浪费、压少了照样爆。

压缩的隐藏成本:它会打掉 prefix cache

压缩不是零成本操作。它改写了 system 段之后的内容,而 prefix cache 的命中条件是请求前缀逐 token 相同——压缩一次,从压缩位置往后的缓存全部失配,这一轮相当于重新 prefill 全部上下文。

工程含义是反直觉的:频繁小幅压缩比一次性大幅压缩更亏。每次压缩都要付一次全量 prefill 的钱,压 10 次小的 = 10 次缓存失配;压 1 次大的 = 1 次失配 + 之后更长的稳定期。所以压缩策略应该是「水位到线,一次压狠一点」,而不是「每轮修剪一点保持在线下」。

计价与缓存命中率的量级见 成本与延迟。

多 Agent 下压谁的窗口:规则是每个 Agent 只压自己的窗口,调度方不得代压。理由是压缩需要知道「哪些是本 Agent 的硬约束」,而调度方看不到下游 Agent 的 role prompt 与 facts,代压必然误删。

配套的一条:跨 Agent 共享的事实不进各自的可压缩区,而是放在共享状态区(黑板)里,各 Agent 按需读取。如果同一个事实在 N 个 Agent 的窗口里各存一份,压缩就会让它们各自漂移成不同版本——这就是上下文分裂。黑板的读写协议与并发语义见 Multi-Agent 系统 · 通信机制。

这一节记住三句话

  1. 三层记忆各有独立的写入触发与读取时机,别当成"一个大 dict 分了三段"。pin 住的关键事实必须物理上不进 messages(只在装配时注入 system 段),否则迟早被滑走。
  2. 全窗压缩固定在装配前——那是最后一个能算准 token 总量的时机。工具返回后只做单条截断,轮末只做异步归档,三者分工不能互换。
  3. 三种失效(摘要漂移 / 滑窗误删指令 / 向量误召)都不报错,只表现为"Agent 变笨了"。必须靠主动断言去发现,不能等异常。另:压缩会打掉 prefix cache,所以策略是"到线一次压狠",而非每轮修剪。

三、工具沙箱与权限隔离

打个比方:这五层就是你给容器做加固的那套分层——镜像扫描(进来的东西先查一遍)→ 只读根文件系统(能跑但不能改)→ capability 裁剪(只给必需的权限位)→ seccomp(系统调用白名单)→ 网络策略(出口只放行该放的)。每一层都不指望自己拦住全部,靠的是叠加后没有一条直通路径。类比失效边界:容器加固防的是你自己写的、可信但可能有 bug 的代码——威胁是"意外"。这里防的是模型现场生成、且可能已被提示注入操纵的代码——威胁是"对抗性"的:它会主动试探每一层的缝隙,参数里藏路径穿越、SQL 里藏二次注入。所以默认配置远远不够,且必须假设前四层都可能被绕过,最后靠 L2 不可逆动作的人工闸门兜底。

工具白名单是必要条件,远不是充分条件。完整的工具侧防线有五层——注意它们是串联的,一次工具调用要依次穿过全部五道,任一道拦下就终止:

模型发起一次工具调用 → 依次穿过五道闸 → 才真正执行① Schema参数强校验错误回灌自修
<rect x="138" y="34" width="112" height="52" rx="6" fill="#1e293b" stroke="#38bdf8" stroke-width="1.5"/> <text x="194" y="52" text-anchor="middle" fill="#e0f2fe">② 危险分级</text> <text x="194" y="66" text-anchor="middle" fill="#94a3b8">L0 / L1 / L2</text> <text x="194" y="79" text-anchor="middle" fill="#94a3b8">查注册表非模型</text> <rect x="268" y="34" width="112" height="52" rx="6" fill="#1e293b" stroke="#38bdf8" stroke-width="1.5"/> <text x="324" y="52" text-anchor="middle" fill="#e0f2fe">③ 执行隔离</text> <text x="324" y="66" text-anchor="middle" fill="#94a3b8">独立进程/容器</text> <text x="324" y="79" text-anchor="middle" fill="#94a3b8">超时/内存/网络</text> <rect x="398" y="34" width="112" height="52" rx="6" fill="#1e293b" stroke="#38bdf8" stroke-width="1.5"/> <text x="454" y="52" text-anchor="middle" fill="#e0f2fe">④ 凭据句柄</text> <text x="454" y="66" text-anchor="middle" fill="#94a3b8">只见 conn 名</text> <text x="454" y="79" text-anchor="middle" fill="#94a3b8">运行时才注入</text> <rect x="528" y="34" width="112" height="52" rx="6" fill="#1e293b" stroke="#38bdf8" stroke-width="1.5"/> <text x="584" y="52" text-anchor="middle" fill="#e0f2fe">⑤ 返回截断</text> <text x="584" y="66" text-anchor="middle" fill="#94a3b8">摘要 + 句柄</text> <text x="584" y="79" text-anchor="middle" fill="#94a3b8">全文惰性回读</text> 
拦下即终止:参数幻觉 ↑ / L2 转人工闸门 ↑ / 逃逸被限制在容器内 ↑② 的 L2 是唯一"拦下但不算失败"的闸——它把调用转成一份待人工确认的申请。前四道都可能被对抗性输入绕过,所以不可逆动作永远不靠前四道兜底。

1. Schema 强校验 + 错误回灌。 校验失败时不要只回「参数错误」,把 schema 本身塞回 error message——模型能读懂 schema 并自我修正,这一步能消掉绝大部分参数幻觉的重试成本。

2. 危险动作三级分级。 这是白名单之上真正起作用的一层:

级别判据处理
L0 只读无副作用,可任意重试直接执行,可并行
L1 可写可回滚有副作用但有反向操作执行 + 记录回滚句柄,串行
L2 不可逆删除、扣款、重启、发布人工闸门,Agent 只能提交申请

分级写在工具注册表里,不由模型判断。

3. 执行侧隔离。 工具跑在独立进程/容器里,配:超时(硬 kill)、内存与 CPU 上限、文件系统只挂载必要路径、网络出口白名单。理由很直接——工具代码要处理模型生成的任意参数,它是攻击面。

4. 凭据不进上下文。 模型永远只见句柄:

# ✗ 错:凭据进了工具参数,也就进了上下文和日志
{"tool": "query_db", "args": {"dsn": "mysql://root:passwd@10.0.0.1/game"}}

# ✓ 对:模型只知道有个叫 game_readonly 的连接
{"tool": "query_db", "args": {"conn": "game_readonly", "sql": "SELECT ..."}}

凭据由 Runtime 按 conn 名查出、注入执行侧。这样即使发生提示注入(详见 LLM 应用安全)或上下文泄漏,泄的也只是句柄名。

5. 返回截断 + 惰性回读。 工具返回先落盘,只把摘要 + 句柄回给模型:

MAX_INLINE = 4096

def wrap_result(raw: str, call_id: str) -> str:
    if len(raw) <= MAX_INLINE:
        return raw
    blob_store.put(call_id, raw)
    head, tail = raw[:1500], raw[-500:]
    return (f"{head}\n...[已截断 {len(raw) - 2000} 字符]...\n{tail}\n"
            f"完整内容用 read_full(call_id='{call_id}', offset=…) 读取")

为什么不能让模型自己判断危险:它对「危险」的判断来自训练分布,而危险性是部署环境的属性——同一句 DROP TABLE tmp_x 在测试库是清理、在生产库是事故,模型看不到这个上下文差异。更根本的是,如果攻击者能通过注入让模型「认为」某个操作安全,那这层防线本身就是可绕过的。判断必须在模型之外、由注册表静态声明。

三层落地在一处:工具执行的骨架

class ToolRuntime:
    def __init__(self, registry: dict[str, ToolSpec], budget: Budget):
        self.registry, self.budget = registry, budget

    def execute(self, call: ToolCall, task_id: str) -> ToolResult:
        # ① 白名单:不在注册表里的工具直接拒,不给模型"发明工具"的机会
        spec = self.registry.get(call.name)
        if spec is None:
            return ToolResult.error(
                f"未知工具 {call.name!r};可用工具:{sorted(self.registry)}")

        # ② Schema 校验失败 → 把 schema 回灌,让模型自修正
        try:
            args = spec.schema.validate(call.args)
        except ValidationError as e:
            return ToolResult.error(f"参数不合法:{e}\n期望 schema:{spec.schema.json()}")

        # ③ 危险分级:L2 不执行,转人工闸门
        if spec.level is Level.IRREVERSIBLE:
            ticket = approval.submit(task_id, call)
            return ToolResult.pending(f"已提交审批 {ticket},等待人工确认后重试")

        # ④ 幂等:同任务内同指纹的写操作直接返回上次结果
        fp = fingerprint(call.name, args)          # 已剔除 nonce/时间戳
        if spec.level is not Level.READONLY:
            if cached := self.idem_store.get(task_id, fp):
                return cached

        # ⑤ 隔离执行 + 硬超时;凭据按句柄注入,不经过模型
        raw = sandbox.run(spec, args, creds=vault.resolve(spec.needs_creds),
                          timeout=spec.timeout_s, mem_mb=spec.mem_limit)
        self.budget.charge_tool(spec.name, raw)

        # ⑥ 截断 + 落盘,只回摘要与句柄
        result = ToolResult.ok(wrap_result(raw, call.id))
        if spec.level is not Level.READONLY:
            self.idem_store.put(task_id, fp, result)
        return result

六步的顺序不能换:白名单在最前(拒掉不存在的工具最便宜),幂等查询必须在隔离执行之前(否则重试会真的重复执行),截断必须在回填之前(否则大输出已经进了上下文)。

这一节记住三句话

  1. 白名单是必要条件,不是充分条件。五道闸串联才成立,且顺序不能换——白名单最前(拒得最便宜)、幂等查询必须在执行之前(否则重试真的重复执行)、截断必须在回填之前(否则大输出已进上下文)。
  2. 危险分级写在工具注册表里,不由模型判断。L0 只读可并行、L1 可回滚要串行加回滚句柄、L2 不可逆只能提交申请走人工闸门。
  3. 凭据绝不能进上下文——模型只见 conn 名这类句柄,真凭据由运行时在执行侧注入。这不是"最好别给",因为模型会把上下文里的任何东西写进输出,一旦进过就可能出现在日志、回复或被注入套走。

四、并发、预算与幂等

哪些工具能并行

判据只有一条:是否只读且互不依赖。

  • 可并行:一批 L0 只读调用,且后者的参数不来自前者的返回。典型如「同时查 5 个服务的健康状态」。
  • 必须串行:任一 L1/L2 有副作用的调用;或后者参数依赖前者观察结果(查出实例 ID → 重启该实例)。

有一个隐蔽代价:并行结果的回填顺序。如果按完成时间回填,同样的输入两次运行会得到不同的上下文顺序,于是模型的决策也不同——离线回放(见 评测与线上运营)就复现不出来了。修法是按发起顺序回填,哪个先返回都放回它原本的位置。

三重预算的计量点

预算计量点触顶动作
token每次模型调用后累加 usage.input + usage.output;工具返回按截断后的长度计进 ABORTED,优雅收尾
时长任务启动时打点,每次进 REFLECTING 比对同上
费用token × 单价,分模型累加(路由到小模型的部分单价不同)同上

三个都要有,因为它们互相拦不住对方:一个便宜模型能在预算内烧掉两小时;一次超长上下文调用能在两分钟内烧掉全部费用。

熔断后要优雅收尾,不是抛异常

这是很多实现的通病——预算耗尽直接 raise BudgetExceeded,调用方拿到一个异常,前面 20 轮的工作全部作废。正确做法是把已有结果交出去:

def finalize(ctx: Context, reason: str) -> AgentResult:
    partial = llm(
        "预算已耗尽。基于已收集的信息给出当前最佳结论,"
        "并明确列出哪些部分未完成、下一步该查什么。",
        ctx.assemble(),
    ).text
    return AgentResult(
        status="incomplete", reason=reason, answer=partial,
        facts=ctx.facts, resume_token=ctx.checkpoint_id,   # 可续跑
    )

有 resume_token,人工看过之后可以加预算续跑,而不是从零重来。

幂等键与重试

  • 幂等键:task_id + tool_name + args_fingerprint。三段都要——只用参数指纹会让两个不同任务的相同操作互相命中;只用 task_id 无法区分同任务内的不同调用。
  • 去重窗口:与任务生命周期同长(任务结束即清),不要设成全局永久——否则重跑同一个任务会拿到上一次的陈旧结果。
  • 该重试的:超时、5xx、连接重置、限流 429(指数退避 + jitter)。
  • 不该重试的:4xx 参数错(重试还是错,应该回灌 schema 让模型改)、权限拒绝、L2 审批被拒。对这些做退避重试只是把预算烧光。

为什么这么做

为什么状态机的收益不只是「代码好看」

三个具体的能力,隐式 while 循环给不了:

  1. 可观测:状态即 span 名。一次任务的 span 树天然按 PLANNING → CALLING_TOOL → OBSERVING 展开,出问题看树形就知道卡在哪个状态、循环在哪两个状态之间——这是 评测与线上运营 里排查路径的基础。
  2. 可恢复:状态 + current_focus + facts 就是重启的全部输入。
  3. 可测试:能直接构造「REFLECTING 状态 + 预算已耗尽」这个输入,单测 finalize 的行为,不必跑完整循环。

规模锚点:状态机的收益随任务步数增长。预期 1~3 步的简单工具调用(问天气),显式状态机是过度设计;步数 >5 或任务时长 >30 秒就开始明显划算。

为什么记忆要分三层而不是两层

省掉中期层(只留短期 + 向量库)看起来更简洁,实际会坏在一个地方:向量库是按相似度召回的,而"本任务的硬约束"不该靠相似度碰运气。用户说的"不要动生产库"必须每轮都在上下文里,不能等某轮的 query 恰好与它语义相近才被召回。中期层的本质是一块无条件注入的常驻区——它和长期层的区别不是新旧,是必然注入 vs 按需召回。

为什么幂等键要带 task_id

反面案例:把幂等键设成 tool_name + args_fingerprint 的全局键。玩家 A 的补偿发放和玩家 B 的补偿发放参数不同没问题,但同一个玩家的两次合法补偿(比如两次不同活动的同额补偿)会撞成同一个指纹,第二次被幂等层吞掉,变成漏发。加上 task_id 后,同任务内防重试重复,跨任务互不干扰——这与游戏侧订单幂等的做法一致(详见 幂等设计)。

为什么别的选择不行

「加大上下文窗口就不用管记忆了」

上下文涨到 1M 之后压缩确实能推后,但三个问题一个都没消失:

  • 成本线性涨:每轮全量注入 500k token,一个 30 轮任务就是 15M input token。
  • 注意力稀释:长上下文里中间位置的信息召回率显著下降("迷失在中间",详见 RAG),关键约束埋在第 300k 个 token 处和不在没多大区别。
  • 崩溃恢复仍需最小状态:进程重启后你还是得回答「重新装配什么」——没有 facts 就只能重跑。

所以大窗口是把压缩水位线抬高,不是取消分层。

「让模型自己决定何时压缩 / 何时停止」

试过的人都会撞到同一堵墙:做出错误决策的和判断该不该停的是同一个模型。它已经跑偏了,它对"我是否跑偏"的判断也一起跑偏。开篇那个烧 1800 次调用的 Agent,每一轮都真诚地认为下一次查询会有结果。

停止判定必须是运行时的外部规则(指纹重复、零新事实、预算),模型的自评只能作为加分信号,不能作为唯一依据。

「工具白名单足够了,不需要沙箱」

白名单管的是「能调哪个工具」,管不了「工具被喂了什么参数」。一个白名单内的 run_sql(conn, sql) 工具,配上模型生成的任意 SQL,等价于把数据库交出去。分级(只读连接 vs 可写连接)+ 隔离(超时、资源上限)+ 凭据句柄化,管的是同一个工具在不同参数下的破坏半径——这是白名单这个维度根本覆盖不到的。

三种记忆策略的对照

策略实现成本丢信息风险适用
纯滑动窗口极低高(早期指令必丢)短任务(<5 轮)、无硬约束
滑窗 + 周期摘要中中(摘要漂移)中等任务,约束不多
三层 + pin高低长任务、有硬约束、需崩溃恢复

不要一上来就上三层——先量一下任务的实际轮数分布。如果 p95 只有 4 轮,纯滑窗就够,三层的复杂度全是白付的。

沉淀结论

面试常见问题清单

调度与终止

  • Q:Agent 循环为什么要显式状态机化? A:换来可观测(状态即 span)、可恢复(状态+focus+facts 即断点)、可测试(能构造单个状态做单测);隐式 while 三样都拿不到。
  • Q:怎么判定 Agent 在原地打转? A:两个可算信号——调用指纹重复(先剔除 nonce/时间戳再 hash,否则拦不住)+ 连续 N 轮零新事实(本轮新增事实键集合为空)。单靠前者会被参数微调绕过。
  • Q:预算耗尽了怎么办? A:不抛异常。让模型基于已有信息出一个 partial 结论 + 未完成清单,带 resume_token 返回,人工加预算可续跑。

记忆

  • Q:压缩水位线怎么定? A:W ≤ ctx_limit − (最大工具输出 + 预期模型输出 + 余量),经验 0.6×上限。贴到 0.9 会在压缩刚做完时被一个大工具返回击穿。
  • Q:压缩应该放在一轮的哪一步做? A:全窗压缩固定在装配前——只有那时本轮内容全部确定、token 算得准,且它是最后一个还能改的时机。工具返回后只做单条截断(不知道本轮还会调几次工具,全窗压容易压过头);轮末只做异步归档(请求已发出,爆窗已经发生,对本轮无救)。附带成本:压缩会改写 system 段之后的内容、打掉 prefix cache,所以频繁小幅压缩比一次性大幅压缩更亏。
  • Q:pin 的关键事实怎么保证不被滑走? A:它物理上不在 messages 里——每轮在 assemble 时重新注入 system 段。实现成"插一条 message 在前面"迟早会被滑出去。
  • Q:长期记忆为什么不能每轮召回? A:每轮塞 3~5 段历史既烧 token 又引噪声,还会误召"语义相近但结论相反"的历史。只在任务启动与模型显式 recall() 时召回。

工具与并发

  • Q:白名单之外还需要什么? A:Schema 校验 + 错误回灌、危险三级分级(只读/可回滚/不可逆走人工闸门)、进程隔离与资源上限、凭据句柄化(模型只见 conn 名)、返回截断 + 惰性回读。
  • Q:为什么不能让模型判断操作危险不危险? A:危险性是部署环境属性(同一句 SQL 在测试库和生产库后果不同),模型看不到;且这层若可被说服就等于可被注入绕过。判断必须由注册表静态声明。
  • Q:哪些工具能并行?回填要注意什么? A:只有 L0 只读且参数互不依赖的能并行;必须按发起顺序回填而非完成顺序,否则同一输入两次运行的上下文顺序不同,离线回放无法复现。

心法总结

运行时的价值不在让 Agent 更聪明,在于让它坏得可控、可查、可续。 模型负责决策,运行时负责兜底——把「什么时候该停」「什么操作不能做」「凭据谁持有」这三件事交给模型,就是把生产事故的开关交给一个不知道自己在生产环境的东西。

本域延伸

  • 枢纽结论与范式对照:Agent 开发
  • 多 Agent 的拓扑、通信、冲突裁决与 MAS 特有失效:Multi-Agent 系统
  • 这些护栏怎么被量化验证:Agent 评测与线上运营
  • 长期记忆的检索链路细节(chunking / rerank / 混合检索):RAG
  • token 计价、TTFT/TPOT 与降本手段:成本与延迟
  • 提示注入如何放大工具权限问题:LLM 应用安全
  • 这套运行时在游戏巡检场景的参数选择:AI 与游戏研发周期

记忆口诀

  • 六状态:PLANNING / CALLING_TOOL / OBSERVING / REFLECTING / DONE / ABORTED
  • 四条终止线:终态 / 轮数 / 预算 / 无进展
  • 无进展两信号:指纹重复(先剔 nonce)+ 连续零新事实
  • checkpoint 五字段:task_id / turn / state / current_focus / facts + 预算
  • 记忆三层口径:短期无条件追加、中期越线即压(0.6 上限)、长期按需召回
  • 压缩三阶段:全窗压缩在装配前 / 工具返回后只单条截断 / 轮末只异步归档;压缩会打掉 prefix cache,所以宁可一次压狠
  • 多 Agent 压缩归属:各压自己的窗口,共享事实进黑板不进各自可压缩区
  • facts 铁律:只进 system 段,永不进 messages,所以永不被滑走
  • 工具五道闸:白名单 → Schema 回灌 → 三级分级 → 幂等 → 隔离+截断
  • 并发一句话:只读才并行,按发起顺序回填(不是完成顺序)
  • 预算三重:token / 时长 / 费用,触顶优雅收尾带 resume_token

内容来源

综合整理:Anthropic / OpenAI 官方文档的工具调用与上下文管理章节、LangGraph 状态机编排设计、AI 巡检自研经验(2026-08)。

生态更新快,具体 API 与参数请以官方文档为准;文中阈值(0.6 水位、连续 3 次、4096 截断)为经验值,需按自身任务的步数分布与上下文上限重新标定。

自测:合上资料能说清楚吗?

  1. 把隐式 while True 循环改成显式状态机,具体换来了哪三样能力?什么规模下这笔投入不划算?
参考答案

可观测(状态即 span 名,能看出卡在哪、在哪两个状态间循环)、可恢复(状态 + current_focus + facts 就是断点的全部输入)、可测试(能构造单一状态做单测,不必跑完整循环)。不划算的规模:预期 1~3 步、时长几秒的简单工具调用,状态机是过度设计;步数 >5 或时长 >30s 才开始明显划算。

  1. 「Agent 在原地打转」怎么变成两个可判定的信号?为什么只有调用指纹去重会失效?
参考答案

信号一 · 调用指纹重复:hash(tool_name + canonical_json(args)),连续 3 次同指纹即中止。信号二 · 连续 N 轮零新事实:每轮新增事实键集合与累计集合求差,差集为空计一次,连续 3 轮为零即中止。只用指纹会失效:参数里若含时间戳 / trace_id / nonce,每次指纹都不同,去重完全拦不住——必须先剔除这些字段再算指纹;且模型可能「换着花样查但什么都没查到」,这只有零新事实信号能拦。

  1. 压缩水位线为什么不能贴着上下文上限设?公式是什么?
参考答案

W ≤ ctx_limit − (max_tool_output + expected_model_output + safety_margin),经验取 0.6 × ctx_limit。贴到 0.9 的后果:压缩刚做完、可压缩的东西已经压完了,此时下一个工具返回一个大 JSON 就直接爆窗,无从补救。必须给「一轮的呼吸空间」。

  1. 对比 纯滑动窗口 与 三层记忆 + pin:实现成本、丢信息风险、各自适用场景?pin 在实现上的关键点是什么?
参考答案

纯滑窗:成本极低,但早期指令必丢,只适合 <5 轮且无硬约束的短任务。三层 + pin:成本高,丢信息风险低,适合长任务 / 有硬约束 / 需崩溃恢复。pin 的关键点:关键事实物理上不进 messages,而是每轮在 assemble 时重新注入 system 段——所以滑动窗口碰不到它。若实现成「插一条 message 在最前面」,它迟早会被滑出去。选型建议先量任务的实际轮数分布,p95 只有 4 轮就别上三层。

  1. 为什么「让模型自己判断这个操作危险不危险」这条路走不通?替代方案是什么?
参考答案

两个理由:① 危险性是部署环境的属性——同一句 DROP TABLE tmp_x 在测试库是清理、在生产库是事故,模型看不到这个差异,它的判断只来自训练分布。② 这层防线若能被说服就等于能被绕过——攻击者通过提示注入让模型「认为」操作安全即可。替代:判断在模型之外,由工具注册表静态声明三级(L0 只读 / L1 可写可回滚 / L2 不可逆走人工闸门),配合进程隔离、资源上限、凭据句柄化。

  1. 并行工具调用有个隐蔽代价与离线回放直接相关,是什么?怎么修?
参考答案

回填顺序。若按完成时间回填观察结果,同样的输入两次运行会得到不同的上下文顺序,模型决策随之不同,离线回放就复现不出来了。修法:按发起顺序回填——哪个先返回都放回它原本的位置。另外并行只能放 L0 只读且参数互不依赖的调用,有副作用或后者依赖前者结果的必须串行。

最近更新: 2026/9/10 11:38
Prev
Agent 开发
Next
Multi-Agent 协作