治理约 15 分钟

Rail:在执行过程中设卡

13 个生命周期事件决定在哪里插,几种控制动作决定能做什么。亲手装一道 Rail,拦住一次不该发生的写订单。

依据 agent-core 0.1.20 · develop@fbe6a0d9(2026-11-13)核实

为什么需要 Rail

大模型会提出要调用什么工具,但它不知道这一刻库存有没有变、这个人有没有权限、算出来的结果是不是已经过期。提示词可以提醒它,却不能保证它照做。

Rail 解决的就是这个问题:在智能体执行过程的固定位置插入你写的代码,由代码而不是模型来决定能不能继续。

openJiuwen 源码里对三者的分工说得很直白:

管什么
Prompt模型一开始知道什么
Tool模型能做什么
Rail模型不许做什么

在哪里插:13 个事件

一个 Rail 就是一个类,覆写哪个方法,就挂在哪个事件上。事件落在三层循环上:

层事件何时触发
整次调用BEFORE_INVOKE / AFTER_INVOKEagent.invoke() 开始前、结束后
外层任务循环BEFORE_TASK_ITERATION / AFTER_TASK_ITERATION每轮任务迭代前后
输入ON_USER_MESSAGE一条输入写进对话之前,Rail 可以改写内容
输入BEFORE_STEERING_DRAIN从队列取出待处理的引导指令之前
模型调用BEFORE_MODEL_CALL / AFTER_MODEL_CALL调用大模型前后
模型调用ON_MODEL_EXCEPTION模型调用抛异常时
工具调用BEFORE_TOOL_CALL / AFTER_TOOL_CALL工具执行前后
工具调用ON_TOOL_EXCEPTION工具执行抛异常时
内层 ReAct 循环AFTER_REACT_ITERATION一轮"模型 + 全部工具"完全成功之后
源码AgentCallbackEvent 枚举agent-core/openjiuwen/core/single_agent/rail/base.py:478@fbe6a0d9

两个容易记错的地方:

  • BEFORE_TOOL_CALL 是工具触碰外部系统前的最后一个钩子。 要拦写操作,就放这里。
  • AFTER_REACT_ITERATION 只在整轮成功时触发,任何中途退出的路径都不会触发,不能拿它兜底。

能做什么:控制动作

挂上钩子之后,Rail 通过上下文对象 ctx 或返回决策来影响执行:

动作效果用在哪
reject跳过这次工具执行,把你给的结果当作工具返回值交还模型BaseInterruptRail,工具执行前
interrupt挂起,等人裁决;答复回来后从断点继续BaseInterruptRail,工具执行前
approve放行,可以顺便改写工具参数BaseInterruptRail,工具执行前
ctx.request_force_finish(result)立刻结束循环并返回给定结果任意钩子;在 before 钩子里调用时,被包住的操作直接跳过
ctx.request_retry(delay)重试刚才失败的那次调用ON_MODEL_EXCEPTION / ON_TOOL_EXCEPTION
ctx.push_steering(msg)往运行中的智能体塞一条指令,下一次模型调用前被吸收任意钩子

ctx 上还有两个较少用到的请求:request_model_continue(要求模型再迭代一轮)和 request_context_rebuild(重建本次模型请求的上下文窗口)。

源码BaseInterruptRailagent-core/openjiuwen/harness/rails/interrupt/interrupt_base.py:46@fbe6a0d9

一道授权门长什么样

贯穿案例:客户询单,智能体查库存、算出可承诺交期,然后要调用 create_order 写入订单系统。问题是,算交期用的库存快照可能已经过期。这时不能让订单写进去。

继承 BaseInterruptRail,只需要实现一个方法:

import json
from openjiuwen.harness.rails.interrupt.interrupt_base import BaseInterruptRail


class PromiseGateRail(BaseInterruptRail):
    """写订单前检查:引用的交期计算结果是否仍然有效。"""

    def __init__(self, promise_store):
        super().__init__(tool_names=["create_order"])  # 只拦这个工具
        self._store = promise_store

    async def resolve_interrupt(self, ctx, tool_call, user_input, auto_confirm_config=None):
        args = json.loads(ctx.inputs.tool_args)
        if self._store.is_stale(args["promise_id"]):
            # 不执行工具,把这段话当作工具结果还给模型
            return self.reject(tool_result="交期计算结果已过期,库存已变化,请重新计算后再下单。")
        return self.approve()


await agent.register_rail(PromiseGateRail(store))  # 注意 await
说明

这段是按源码接口写的示意代码,promise_store 是你自己的业务对象;本站没有连接真实模型运行它。接口签名以下方链接的源码为准。

要点:

  • 判断依据(结果是否过期)来自你的业务系统,是确定的。不要让模型自己判断自己。
  • reject 之后工具不会执行,模型收到的是你写的拒绝说明,它可以据此重新取数、重新计算。
  • 如果想转人工,把 reject 换成 self.interrupt(request),智能体会挂起等待答复。

动手:装一道 Rail

下面的模拟器里有三个任务,每个任务都要你选一个钩子和一个动作,然后看执行时间线怎么变。

模拟器加载中…

四个不报错的坑

Rail 的麻烦之处在于:装错了通常不会报错,只是悄悄不生效。

一、register_rail 漏写 await。 它是协程。漏写时 Rail 根本没装上,智能体照常运行。模拟器里有一个开关可以亲自体验。

二、after 钩子在失败时也会触发。 after 事件写在 finally 里:模型调用失败、甚至即将重试时,AFTER_MODEL_CALL 照样触发。所以在 after_model_call 里要先看 ctx.exception,不能假设一定有响应。唯一的例外是工具调用即将重试时会跳过 AFTER_TOOL_CALL。

源码@rail 装饰器agent-core/openjiuwen/core/single_agent/rail/base.py:1065@fbe6a0d9

三、在 DeepAgent 上,事件走两条路。 DeepAgent 外层包着一个内层 ReActAgent,模型调用、工具调用这类事件由内层触发,BEFORE_INVOKE 这类由外层触发。框架用三个集合决定每个事件的回调注册到哪一层。源码文档提醒:新增事件如果没放进任何集合,回调一次都不会跑,只有一条警告日志。

源码_BRIDGE_EVENTS 等三个集合agent-core/openjiuwen/harness/deep_agent.py:166@fbe6a0d9

四、priority 数值越大越先。 默认 50,BaseInterruptRail 是 90。同一个数还决定 init 的先后,所以"我的钩子要排在它后面"和"我初始化时它的工具得已经在"是同一个问题。

小结

  • Rail 把约束同步地插进执行过程,位置由 13 个事件决定。
  • 拦写操作放 BEFORE_TOOL_CALL;处理失败放 ON_*_EXCEPTION;校验结果放 AFTER_TOOL_CALL。
  • 判断依据要来自确定性的来源,Rail 只提供拦截点。
  • 装完一定要用测试断言它真的生效了。

源码出处

链接指向固定提交,仓库后续更新不影响这里的行号。

过关测验

  1. 1. 要在工具真正执行之前拦住它,Rail 应该挂在哪个事件上?

  2. 2. 模型调用失败并且 Rail 请求了重试,AFTER_MODEL_CALL 还会触发吗?

  3. 3. 写了 agent.register_rail(MyRail()) 但没有 await,会怎样?

  4. 4. 两个 Rail 挂在同一个事件上,priority 分别是 90 和 50,谁先执行?

通关条件

  • ○ 完成模拟器任务(0/3)
  • ○ 测验全部答对