主题
运行时并发、拦截与终止
一句话:单 Agent 的循环可以用一个
while写完,但生产运行时还要回答四个问题:多个会话能不能同时跑、拦截器谁先谁后、权限模式怎么切、循环什么时候必须停。这四个问题都由程序回答,不能交给模型。
适用:2026 年自建 Agent 运行时。文中的并发度与阈值为起点值,需按模型延迟、工具耗时和 SLA 校准。
为什么需要
Demo 阶段只有一个用户、一条消息、一次调用,所有问题都被掩盖。上生产后三类故障会同时出现:
- 交错写入:同一会话的两条消息并发处理,各自读到旧上下文,各自调用同一个工具,最后互相覆盖状态;
- 拦截器打架:日志、脱敏、限流、审计、权限各自注册,没有顺序规则;后注册的把前一个改写过的参数又改回去,或者一个拦截器抛异常导致整条链静默放行;
- 停不下来:模型反复调用同一个工具,或者每一步都“有进展”但目标永远不达成,直到预算烧完。
这三类问题的共同点是:它们不该由模型判断。模型负责在给定上下文里做选择,运行时负责并发、顺序、权限和终止。
核心机制
一、并发模型:会话内串行,会话间并行
同一会话的上下文是一份共享可变状态。两条消息并发处理会产生读-改-写竞争:A 读到“未下单”,B 也读到“未下单”,两边都去下单。所以最小正确模型是:
| 层级 | 并发度 | 原因 |
|---|---|---|
| 同一会话 | 1(串行) | 上下文与工具副作用共享,必须顺序化 |
| 不同会话 | 并行 | 状态隔离,互不影响 |
| 同一会话的子任务 | 受控并行 | 只允许无依赖、无共享写的子任务并行 |
实现上通常给每个会话一把公平锁或容量为 1 的信号量,消息进入会话队列;跨会话用线程池或事件循环。关键是排队要有上限和超时,否则一个卡住的会话会拖垮整个队列。
Agent 的负载是 IO 密集型:时间花在等模型和等工具,不在算。所以线程数不该按 CPU 核数配,而该按“同时在等的请求数”配;协程或虚拟线程更合适,因为它们在被阻塞时让出执行权。反过来说,阻塞操作必须隔离:写文件、写数据库、压缩归档这类同步调用要放到单独的线程池,否则会占住处理推理结果的线程。
这三条的共同原理是故障隔离半径:同会话串行,是因为模型无状态、察觉不到并发请求正在改写同一份上下文——读-改-写交错只能由运行时预先消除,不能事后补救;阻塞操作隔离,是因为一次慢磁盘写如果占住推理回调线程,会把所有共享该线程池的会话延迟一起拖崩——单会话的故障不能外溢成全局故障;排队设上限和超时,是因为没有上限的队列让一个卡住的会话耗尽全部线程,等于把队头阻塞交给容量来扛。三件事的共同点是:保护的不是"这个请求",而是"其他租户的这个请求不受这个请求影响"。
二、拦截链:顺序、短路与异常
拦截器(中间件、Hook)把横切关注点从业务代码里拿出来,但一旦有多个,就必须回答三个问题:
- 谁先谁后:按阶段分组(鉴权 → 限流 → 脱敏 → 审计 → 业务),组内按显式优先级,同优先级按注册顺序;不要依赖“谁先注册谁先跑”这种隐式规则。
- 能不能否决:前置拦截器要能终止整条链(例如鉴权失败、命中黑名单),而不是“打个标记继续跑”。否决语义要显式声明,避免有的拦截器返回
false表示拒绝、有的表示跳过。 - 异常算拒绝还是放行:这是安全边界问题。鉴权、脱敏类拦截器抛异常必须按拒绝处理(fail-closed);日志、埋点类可以放行(fail-open)。每个拦截器都要标注自己的失败姿态,不能统一处理。
后置拦截器同理:它们看到的是“已经发生的结果”,能改输出、能补审计,但不能撤销副作用;需要撤销的动作应该在执行前拦截。
环绕型拦截器(进入前、退出后都执行)要保证资源释放与进入对称,否则异常路径会泄漏连接、文件句柄或会话锁。
顺序和失败姿态都不是风格问题。顺序的原理是信息依赖:限流要按身份分桶,必须跑在鉴权之后,否则一个匿名洪水就把全局配额打满;审计要记录脱敏后的内容,必须跑在脱敏之后,否则日志本身成为泄漏面。失败姿态的原理是失效代价不对称:安全类拦截器异常若放行,等于攻击者只要让拦截器崩溃就绕过了控制——安全强度退化为"这条代码从不抛异常";观测类若 fail-closed,掉一份埋点就毁一次用户请求,为不可用的可观测性付出可用性。所以每个拦截器必须自己声明属于哪一类,统一 try/catch 包住整条链就是把两类失效代价混成一种。
三、权限:从三结果到模式矩阵
allow / deny / ask 只是单次判定的结果,生产上还需要“当前处于什么模式”。常见五种模式:
| 模式 | 行为 | 适用 |
|---|---|---|
| 只读 | 只允许读类工具 | 探索、演示 |
| 计划 | 只出方案,不执行写操作 | 高风险变更前的评审 |
| 编辑 | 写操作逐次确认 | 日常开发 |
| 自动 | 白名单内自动执行,其余确认 | 成熟流程 |
| 完全 | 不做交互确认 | 受控批处理 |
模式与风险等级要交叉:高风险动作(删除、发布、支付、对外发送)在任何模式下都不能“完全自动”,必须保留确认或二次校验。计划模式也需要白名单:读取、检索、生成预览这类无副作用操作要放行,否则计划模式会连资料都查不了。
模式切换本身要审计:谁切的、为什么切、切之前在做什么。模式是权限状态的一部分,不能只存在前端。
四、终止闸门:四道硬约束
“让模型自己判断什么时候停”在长任务里不可靠。运行时至少要设四道闸门,且都是代码判断:
- 步数上限:超过 N 步强制结束,返回已完成部分和未完成原因;
- 重复检测:相同工具 + 相同参数 + 相同上下文指纹出现多次,判定为原地打转;
- 无进展检测:连续若干步没有产生新的有效结果(没有新信息、没有状态变更),降级或终止;
- 时间与成本:全局 Deadline 向下传播,预算耗尽先降级(换小模型、减工具),再终止。
四道闸门要有优先顺序:先降级、再压缩、最后终止。直接终止会让用户拿到半成品,降级至少能给出可用结果。
五、事件到传输:让前端看见过程
运行时把内部状态变成事件(思考开始/结束、工具调用、结果、状态变更、等待审批),再通过 SSE 或 WebSocket 推给前端。三个工程点:
- 块级生命周期:文本、思考、工具调用、结果各自有开始/增量/结束,前端按块渲染,不必等整条消息完成;
- 事件序号:断线重连时按序号补发,避免重复渲染或丢块;
- 背压:前端消费慢时要能丢弃中间增量、保留最终结果,不能让事件队列无限增长。
图里拦截链在循环之前,终止闸门在循环之内:前者决定“能不能开始”,后者决定“什么时候必须停”。两者位置放反,就会出现“跑完了才被鉴权拒绝”。下方虚线是旁路:事件流跟着循环走,只决定前端能看到什么,不参与终止判定,所以它缺失只影响体验,不影响任务正确性。
实战场景
案例:一次配置迁移把鉴权排到日志后面——隐式顺序一定会被破坏
现象:运行时改造时把拦截器从显式 priority 改成按注册顺序装配,迁移脚本按名称字母序生成列表。上线 40 分钟内(经验值,需校准)出两件事:匿名洪水把全局配额消耗九成(经验值,需校准),真实租户大面积排队;同期审计日志里出现明文手机号,并随导出进入分析平台——泄漏面从单个请求挪到了日志系统。
排查过程:先看网关层——网关鉴权与限流指标正常,没有被绕过,故障在进程内运行时。取一条请求的拦截链 span,实际装配是 audit → log → redact → rate_limit → auth,三条判据同时命中:限流读到的身份字段为空,因此按全局单桶计数,这是“限流要按身份分桶,必须跑在鉴权之后”的反面;审计读到的内容早于脱敏,正是“否则日志本身成为泄漏面”;模型调用量与工具耗时分布不变,模型层排除。顺序被打断不会抛错,只会静默算错桶、静默写明文。
对症做法:恢复按阶段分组、组内显式优先级,注册顺序只做同优先级稳定排序;再把信息依赖写成启动期断言,让顺序错误在启动时失败而不是运行时静默降级。
yaml
interceptors:
order_by: [phase, priority, registration] # registration 只负责稳定排序,不承担语义
startup_assertions: # 任一不通过则拒绝启动
- {edge: auth_before_rate_limit, check: "rate_limit.identity_source == auth.output"}
- {edge: redact_before_audit, check: "redact in audit.upstream"}
- {edge: auth_veto_explicit, check: "auth.phase == pre and auth.veto == terminate"}复盘要点:正文那句“不要依赖谁先注册谁先跑这种隐式规则”就是这次的成因;隐式规则在配置迁移、批量注册、依赖注入容器重排时必然被破坏。顺序的判据是信息依赖,而信息依赖是可校验的契约——写成断言的成本远低于一次配额事故。
案例:一个统一 try/catch 让权限在 6 分钟里整体失效
现象:一次“不能让任何拦截器打挂主链路”的改造把整条链包进同一个 catch,异常即放行。某晚身份服务抖动 6 分钟(经验值,需校准),鉴权在 1.2 万次请求(经验值,需校准)上抛异常并被全部容错放行——等价于这 6 分钟权限不设防,3 起越权写操作落地需要人工回滚。同一段时间里审计拦截器一次序列化失败被同一个 catch 吞掉,审计记录出现空洞且没有告警。
排查过程:先定边界在哪一层。网关侧该时段鉴权通过率记为 100%,说明这批内部 RPC 根本没走网关鉴权,运行时是唯一防线——正文口径一致:拦截器跑在进程内,被绕过就全绕过,真正的边界在网关、沙箱和权限系统。再按拦截器名把 6 分钟的异常分组:鉴权类异常数几乎等于放行数,观测类异常数为零但记录条数也为零。前者说明安全强度已经退化成“这条代码从不抛异常”,后者说明异常与静默失效被同一段代码处理成了两种后果。
对症做法:失败姿态按失效代价不对称逐类声明,拆掉统一 catch,并把抛异常路径写进测试。
| 拦截器类别 | 异常时行为 | 失效代价判据 | 必须配套的运维项 |
|---|---|---|---|
| 鉴权、限流、脱敏、权限校验 | fail-closed:拒绝并短路整条链 | 错放即权限失效,不可回滚 | 拒绝率告警;依赖抖动时降级为更强档,如全量转确认 |
| 埋点、日志、审计上报 | fail-open:记告警继续放行 | 错杀即掉一份观测毁一次用户请求 | 本地缓冲加补发,缺数可查 |
| 环绕型(持会话锁或连接) | 与进入对称释放后再判定 | 泄漏会话锁把单会话故障扩到同队列 | 异常分支同样覆盖释放路径 |
yaml
interceptors:
- {name: auth, phase: pre, priority: 10, on_error: deny}
- {name: rate_limit, phase: pre, priority: 20, on_error: deny}
- {name: redact, phase: post, priority: 50, on_error: deny}
- {name: audit, phase: post, priority: 90, on_error: allow, buffered: true}
global:
chain_try_catch: forbidden # 一条 catch 把两类失效代价混成一种
deny_reason_required: true # 拒绝要写明哪个拦截器、因什么异常,供事后归因复盘要点:一次故障里同时出现权限全失效与审计全缺失,是同一个 catch 的两个面。失败姿态不是容错偏好,而是按不可回滚性分类;正文要求每个拦截器标注失败姿态,这条只有能在配置里逐条读出来才算真标了。
案例:一次归档压缩把跨租户会话延迟拖高 28 倍
现象:运维类 Agent 的一次压缩归档任务在同步写 NAS 时遭遇磁盘抖动,单次写 8 秒(经验值,需校准)。15 分钟内(经验值,需校准)全平台首 Token 延迟从 1.4 秒劣化到 40 秒以上,约 28 倍(经验值,需校准),排队超过 30 秒上限而被丢弃的消息 2100 条(经验值,需校准)。告警把它读成模型侧变慢,团队向上游供应商追了两天。
排查过程:按等待发生在哪一层来判。网关与模型层——到模型的连接耗时、首包耗时正常,模型侧 P95 无变化,排除。运行时层——处理推理结果的线程池活跃线程全部处于等待态,栈顶不是等模型而是文件写与压缩调用;CPU 空闲但吞吐塌掉,正是 IO 密集负载被同步调用占住线程的形态。会话层——受害会话与肇事会话不同租户,但共用同一线程池,说明被配错的是隔离边界,不是同会话串行策略。判据一句话:同会话串行只保护同一份上下文的读写顺序,跨会话互相拖累由线程池边界决定,两者不能用一条配置解释。
对症做法:阻塞操作隔离到单独线程池,容量按“同时在等的请求数”核算而不是按 CPU 核数;排队的上限与超时保留。
yaml
runtime:
session: {concurrency: 1, queue_limit: 100, queue_timeout: 30s}
blocking_pool: {size: 4, queue: 100, reject: fast_fail_with_reason,
task_types: [file_write, db_write, archive_compress]}
inference_pool: {kind: virtual_threads, max_concurrent_waiting: 512} # 经验值,需校准
pool_rule: 阻塞类工具只允许提交到 blocking_pool;处理推理结果的线程禁止出现同步写正文的 4/100 是起点值,本例按同时在等的写请求数重算后上调到 12(经验值,需校准);reject 必须带原因返回,不能静默排队,否则容量问题会被读成模型延迟问题。
复盘要点:这就是故障隔离半径被击穿的样子——一次慢磁盘写占住推理回调线程,把共享该池的所有会话延迟一起拖崩,单会话的故障外溢成全局故障。正文那句“保护的不是这个请求,而是其他租户的这个请求不受这个请求影响”,落点就是这条线程池边界;这次真正省掉两天的是把它写成 pool_rule 里一条可检查的规则。
最小上手
yaml
runtime:
session: {concurrency: 1, queue_limit: 100, queue_timeout: 30s}
blocking_pool: {size: 4, queue: 100}
interceptors:
- {name: auth, phase: pre, priority: 10, on_error: deny}
- {name: rate_limit, phase: pre, priority: 20, on_error: deny}
- {name: redact, phase: post, priority: 50, on_error: deny}
- {name: audit, phase: post, priority: 90, on_error: allow}
permission: {mode: edit, high_risk_requires_confirm: true}
stop: {max_steps: 20, same_call_limit: 3, no_progress_limit: 5, deadline: 300s}验收看四件事:同一会话并发投递不会重复执行工具;拦截器抛异常时高风险的拒绝、低风险的放行;模式切换有审计记录;打转任务能在步数或重复检测处停下并说明原因。
生产动作
- 把“同会话串行”写进契约:让上游知道并发投递会被排队,而不是静默交错。
- 拦截器分级:安全类 fail-closed,观测类 fail-open;每个拦截器声明失败姿态,并在测试里覆盖抛异常路径。
- 拦截开销要计量:整条链的额外延迟要进监控;单个拦截器做远程调用会把每轮延迟放大数倍。
- 权限模式默认收紧:默认只读或编辑,自动与完全模式需要显式开通和定期复核。
- 终止原因要可读:返回“步数超限/重复调用/预算耗尽”,而不是笼统的“失败”;否则无法判断该调预算还是改提示词。
- 事件要能回放:事件流落盘后既能给前端重连,也能给评测做轨迹回放,一举两得。
边界
- 单用户工具不需要这套:本地脚本、单人低频使用,会话锁和拦截链都是负担。
- 串行会牺牲吞吐:同一会话的并行请求必须排队;要提速只能拆会话或拆无依赖子任务,不能取消串行。
- 拦截器不是安全边界:它跑在进程内,被绕过就全绕过;真正的边界在网关、沙箱和权限系统。
- 模式越多越难解释:五种模式已经够用,再加会让用户和审计都说不清“当时是什么状态”。
- 硬上限会截断合法长任务:上限要按任务类型分档,长任务用更大的步数与更长的 Deadline,而不是全局放宽。
讲清楚
运行时要回答四个程序问题。并发上,同会话必须串行,因为上下文与副作用共享;跨会话并行,瓶颈是等待而不是 CPU,阻塞操作要隔离到单独线程池。拦截上,按阶段和优先级排序,显式声明能否否决以及异常时 fail-open 还是 fail-closed。权限上,三结果不够,要有模式矩阵,高风险动作在任何模式下都要确认,模式切换要审计。终止上,步数、重复、无进展、Deadline 四道闸门,先降级再终止。
这些机制的共同点是“把判断从模型手里拿回来”。模型负责在上下文里做选择,运行时负责顺序、边界和停止条件;两者混在一起,就会出现交错写入、拦截器互相覆盖、任务停不下来的问题。
适用:2026 年自建 Agent 运行时 | 初稿 2026-10