插件(Plugins)
概述
插件是一种 Runner 作用域(Runner-scoped)的扩展机制,可以在以下生命周期点插入 自定义逻辑:
- Agent 执行
- 模型调用(大语言模型(Large Language Model, LLM)请求)
- 工具(Tool)调用
- 事件(Event)发出
插件主要解决的是 横切关注点:也就是“你希望所有 Agent 都统一具备的能力”,而 不是某个 Agent 的业务逻辑。
常见场景:
- 统一日志与调试
- 安全与策略(policy)拦截
- 请求改写(例如统一追加 system instruction)
- 审计、事件打标
如果你的需求只针对某一个 Agent,通常用回调(Callbacks)会更合适。
名词解释(Glossary)
为了避免“插件 / 回调 / hook(钩子)”混在一起,先把几个词说清楚:
- 生命周期(lifecycle):Runner 处理一次输入的完整过程(创建 Invocation(一次运行 的上下文)→ 调用 Agent → 触发模型调用/工具调用 → 发出事件 → 结束)。
- Hook 点(Hook Point):生命周期里的一个“时机/插槽”。框架在这个时机会去调用
你注册的函数(回调)。在本项目里,它对应
BeforeModel、AfterTool、OnEvent这类位置。 - 回调(callback):你提供给框架的函数。框架在某个 Hook 点被触发时,会调用这 个函数。
- hook(钩子):一个通用说法,通常指“Hook 点”或“挂在 Hook 点上的回调函数”。 在这个仓库里,hook 是通过回调实现的,所以文档里有时会混用“hook/回调”,本质是 同一件事:框架在某个时机调用你的函数。
- 插件(plugin):一个实现了
plugin.Plugin的组件。它在Register(reg *plugin.Registry)里把一组回调注册到多个 Hook 点,然后通过runner.WithPlugins(...)在 Runner 上一次性启用。
一句话:
- Hook 点 = 框架预留的“时机/插槽”
- 回调 = 你写的函数,挂在 Hook 点上
- 插件 = 把一组回调打包,并全局生效
文档示例里常见的变量名:
runnerInstance:Runner 实例(*runner.Runner)reg:注册表(registry),用于注册回调(*plugin.Registry)ctx:上下文(context.Context)
插件和回调有什么区别?
一句话:插件跟着 Runner 走;回调跟着 Agent 走。
如果你只用回调(callback)来实现“全局生效”的效果,你需要把同一套回调逻辑手动加到 这个 Runner 会用到的每一个 Agent 上;插件(plugin)就是把这件事“收拢到一个地方”, 让你只在 Runner 上注册一次,然后自动应用到该 Runner 管理的所有 Agent、工具(Tool) 和模型调用(大语言模型(Large Language Model, LLM)请求)。
回调(callback)是“一个函数”:框架在特定时机(before/after)去调用它。你需要把 它绑定到你想生效的地方(很多时候是某个 Agent 上)。
插件(plugin)是“一个组件”:它把“一组回调 + 配置 + 可选的生命周期管理”打包在 一起,然后 只在 Runner 上注册一次,就能自动对该 Runner 管理的所有 Invocation (一次运行的上下文)生效。
换句话说:
- Hook 点(Hook Point):生命周期中的“时机/插槽”。
- 回调(callback):挂在 Hook 点上的函数(也常被称为 hook)。
- 插件(plugin):把多组回调打包,并在 Runner 上一次注册让它全局生效。
插件和回调的关系(最关键)
插件不是“另一套新的回调系统”。插件的本质就是:在 Runner 级别集中注册一批回调。
具体来说:
- 插件通过
Register(reg *plugin.Registry)把回调注册到各个 Hook 点; reg.BeforeModel(...)/reg.AfterTool(...)这类方法注册的就是“回调函数”;- 运行时框架仍然在对应的生命周期点执行回调:
- 先执行“插件注册的全局回调”
- 再执行“Agent 自己配置的回调”(如果有)
你可以把插件理解为:一组回调(Callbacks)的打包与全局应用。
如果你想了解“回调如何绑定到某一个 Agent(局部生效)”,可以参考 callbacks.md
页面。
一张图看懂:从注册到执行
插件生效分两步:注册 与 执行。
sequenceDiagram
autonumber
participant User as 你的代码
participant Runner as Runner
participant Plugin as 插件(plugin.Plugin)
participant Reg as 注册表(plugin.Registry)
participant Agent as Agent
participant ModelTool as 模型/工具
Note over User,Reg: 1) 注册阶段:只发生一次(创建 Runner 时)
User->>Runner: NewRunner(..., WithPlugins(Plugin))
Runner->>Plugin: Register(Reg)
Plugin->>Reg: reg.BeforeModel(回调)
Plugin->>Reg: reg.OnEvent(回调)
Note over User,ModelTool: 2) 执行阶段:每次 Run 都会发生
User->>Runner: Run(ctx, input)
Runner->>Agent: 调用 Agent
Note over Runner,Agent: 运行到某个 Hook 点(例如 BeforeModel)
Runner->>Runner: 先执行插件回调(全局)
Runner->>Agent: 再执行 Agent 回调(局部,可选)
Runner->>ModelTool: 调用模型/工具(除非被短路)
Runner-->>User: 返回结果
1) 注册:把回调挂到 Hook 点
你在创建 Runner 时启用插件:
其中 MyPlugin 是你自定义的插件类型(实现 plugin.Plugin 接口)。
然后框架会调用插件的 Register(reg *plugin.Registry)。在这个函数里,你通过
reg.BeforeModel(...) / reg.OnEvent(...) 等方法,把“回调函数”注册到对应的
Hook 点。
2) 执行:运行到 Hook 点时,框架调用你的回调
当运行过程中走到某个 Hook 点(例如 BeforeModel)时,框架会按顺序执行:
1) 插件注册的全局回调 2) Agent 自己配置的回调(如果有)
这就是为什么插件能“一次注册,全局生效”:它只是把同样的回调逻辑提前挂到了 Runner 统一管理的生命周期点上。
什么时候用插件?
当你希望“对一个 Runner 管理的所有执行都统一生效”时,用插件更合适:
- 想对所有 Agent 做统一策略(例如拦截某些输入)。
- 想统一做日志、指标(metrics)、链路追踪(tracing)。
- 想对每一次模型请求做统一改写(例如加 system instruction)。
- 想给所有事件统一打标,便于审计与排查。
什么时候用回调?
当你的需求只影响某个特定 Agent 时,用回调更合适:
- 只有一个 Agent 需要特殊的请求改写。
- 只有一个 Agent 的工具需要自定义参数处理。
- 你在快速试验,不希望全局影响其他 Agent。
你也可以混用:插件负责全局默认行为,回调负责单个 Agent 的定制。
快速开始
创建 Runner 时注册插件(只需要注册一次):
插件是如何执行的?
作用域与传播
- 插件在 Runner 创建时初始化一次,并存放到 Invocation 的
Invocation.Plugins中。 - 当 Invocation 被 Clone(例如子 Agent、AgentTool、transfer 等)时,同一个插件管理器 会被带过去,所以插件在嵌套执行中仍然一致生效。
在回调里拿到 Invocation
在 BeforeModel / AfterModel / BeforeTool / AfterTool 这类回调里,你通常只拿到
context.Context。如果你需要当前 Invocation,可以从 context 里取出来。
下面示例用 fmt.Printf 打印(示例省略了 import "fmt"):
在工具回调里,框架还会把工具调用标识符(identifier, ID)注入 context:
执行顺序与短路(short-circuit)
插件会 按注册顺序执行。
某些 Before* 回调支持“短路”默认行为:
- BeforeAgent:可以返回自定义响应,直接跳过 Agent 执行。
- BeforeModel:可以返回自定义响应,直接跳过模型接口调用 (应用程序编程接口(Application Programming Interface, API))。
- BeforeTool:可以返回自定义结果,直接跳过工具执行。
某些 After* 回调支持“覆盖”输出:
- AfterAgent:可以返回自定义响应,作为一条额外的“终态”响应事件追加到 Agent 事件流末尾(不替换之前的事件)。
- AfterModel:可以返回自定义响应,替换模型响应。
- AfterTool:可以返回自定义结果,替换工具结果。
多 Agent 场景下的注意事项(ChainAgent、ParallelAgent、CycleAgent、Graph Agent 节点)
BeforeAgent/AfterAgent会为每一个子 Agent invocation 各触发一次, 而不是每个 Runner 执行只触发一次。如果你的 Hook 假设“一次 turn 只调用一次”, 请通过args.Invocation.Agent(或AgentName)来区分当前是哪一层。
BeforeAgent.CustomResponse会完全短路子 Agent:Run不会被调用, 子 Agent 自己发出的终态事件(例如 Graph Agent 节点依赖的GraphCompletionEvent,它负责填充SubgraphResult.FinalState)也不会 被发出。自定义的outputMapper需要在FinalState为nil时也能正常处理。
AfterAgent.CustomResponse会追加一条终态响应事件。在 Graph Agent 节点 里,这条追加的响应会成为下游节点看到的StateKeyLastResponse。如果你就是 想让 Runner 作用域插件覆盖子 Agent 输出,请有意识地使用它。
错误处理
- agent/model/tool 回调返回 error 会让本次运行失败(错误返回给调用方)。
OnEvent返回 error 时,Runner 会记录日志并继续使用原始事件。
并发(很重要)
工具可能并发执行,某些 Agent 也可能并发运行。如果你的插件会保存共享状态,请确保
并发安全(例如使用 sync.Mutex 或原子(atomic)类型)。
Close(资源释放)
如果插件实现了 plugin.Closer,当你调用 Runner.Close() 时,Runner 会调用插件的
Close() 来释放资源。关闭顺序是 按注册顺序的反向(后注册的先关闭)。
Hook 点(Hook Points)
Agent Hook 点
BeforeAgent:Agent 开始前AfterAgent:Agent 事件流结束后
Model Hook 点
BeforeModel:模型请求发出前AfterModel:模型响应产生后
Tool Hook 点
BeforeTool:工具调用前,可以修改工具参数(JSON(JavaScript Object Notation) 字节)AfterTool:工具调用后,可以替换结果
Event Hook 点
OnEvent:Runner 发出每一个事件时都会调用(包括 runner completion 事件)。你可以 原地修改事件,或者返回一个新的事件作为替代。
Graph 节点 Hook(StateGraph / GraphAgent)
Runner 插件不会提供 BeforeNode / AfterNode 这类“节点级”的 Hook 点。
Graph 的节点执行发生在图引擎内部(graph.Executor),它有自己独立的一套回调机制:
graph.NodeCallbacks。
如果你希望对 Graph 节点 做“切面/横切”,常见有三种方式:
1) 使用 Graph 节点回调(推荐)
Graph 同时支持:
- 全局(图级)回调:构图时通过 (*graph.StateGraph).WithNodeCallbacks(...)
一次性注册,对该图内所有节点生效。
- 单节点回调:通过 graph.WithPreNodeCallback /
graph.WithPostNodeCallback / graph.WithNodeErrorCallback 只挂到某个节点上。
详细的手把手教程见 graph.md。
2) 用 OnEvent 观测 Graph 节点生命周期事件
Graph 会发出任务生命周期事件(event object):
- graph.node.start
- graph.node.complete
- graph.node.error
插件可以在 OnEvent 里对这些事件做打标/日志/审计等:
注意:如果你显式开启了 StreamMode 过滤,需要在 modes 里包含 tasks
(或 debug)才能把这些事件转发给调用方;如果你没有配置 StreamMode(默认),
Runner 不会做过滤,会把所有事件都转发。无论哪种情况,插件都能看到这些事件,
因为 OnEvent 在 StreamMode 过滤之前执行。
3) 从插件里注入 Graph 全局节点回调(进阶)
如果你希望“Runner 作用域”的逻辑也能影响 Graph 节点回调,可以在 BeforeAgent 里
往 Invocation.RunOptions.RuntimeState 写入 graph.StateKeyNodeCallbacks。
Graph 会把这个 key 当作“本次运行的全局节点回调”来使用。
可运行的端到端示例见 examples/graph/runner_plugin_node_callbacks。
常见用法(Recipes)
1) 拦截输入并短路模型调用(策略)
用 BeforeModel 在模型调用前直接返回自定义响应:
2) 给所有事件打标(审计/排查)
用 OnEvent 给事件追加 tag,便于 UI(User Interface)过滤或日志检索:
3) 改写工具参数(清洗/规范化)
用 BeforeTool 替换工具参数(JSON(JavaScript Object Notation)字节):
内置插件
Logging
plugin.NewLogging() 会记录 agent/model/tool 的开始与结束信息,适合用于调试与
性能分析。
GlobalInstruction
plugin.NewGlobalInstruction(text) 会在每一次模型请求前,统一追加一条 system
message。适合用来实现全局策略或统一行为(例如安全约束、风格要求)。
ToolCallID
plugin/toolcallid 下的 toolcallid.New() 用于在模型返回最终 ToolCall.ID 后统一改写为框架使用的 tool call ID。当 provider / model 不能稳定保证 ToolCall.ID 足够唯一时,可以启用这个插件。
使用示例如下:
该插件挂载在 AfterModel,会在最终可用的 tool call ID 上做统一改写。改写完成后,框架后续处理会继续使用该 ID。
如果其他插件也在 AfterModel 阶段依赖最终 ToolCall.ID,应把 toolcallid.New() 放在它们前面。
完整示例见 examples/toolcallid。
Guardrail(护栏)
plugin/guardrail 下的 guardrail.New(...) 是顶层插件入口,用来把一个或多个护栏能力接到 Runner 上。
当前内置的 capability 包括:
| Capability | Hook | 决策对象 | Reviewer 要求 |
|---|---|---|---|
approval |
BeforeTool |
当前工具动作 | 仅当工具路径可能走到 ToolPolicyRequireApproval 时必填 |
promptinjection |
BeforeModel |
最后一条 role=user 输入 |
必填 |
unsafeintent |
BeforeModel |
最后一条 role=user 输入 |
必填 |
一个典型的顶层组合方式如下:
你可以只挂载其中任意一部分能力。guardrail.New(...) 也允许空构造,适合先把顶层插件挂好,再按需补 capability。
Approval(审批)
plugin/guardrail/approval 下的 approval.New(opts...) 用于构造内置的工具审批 capability。这个 capability 会拦截
BeforeTool,并决定本次工具调用应该:
- 直接执行
- 直接拒绝
- 进入 reviewer 审批
典型接法如下:
上面这段配置表达的是:
- 未显式配置的工具默认使用
ToolPolicyRequireApproval,先进入 reviewer 审批。 hostexec_write_stdin使用ToolPolicySkipApproval,直接放行,不进入 reviewer。hostexec_kill_session使用ToolPolicyDenied,直接拒绝,不执行工具。
如果所有工具路径都不会走到 ToolPolicyRequireApproval,也可以不注入 reviewer,只使用静态策略。
三种策略的行为差异如下:
| 工具策略 | 行为 | reviewer 是否参与 |
|---|---|---|
ToolPolicyRequireApproval |
构造审批请求并等待 reviewer 决策。 | 是 |
ToolPolicySkipApproval |
直接执行工具,不打印审批日志。 | 否 |
ToolPolicyDenied |
直接返回拒绝结果,不执行工具。 | 否 |
如果你使用的是 review.New(...) 创建出来的内置 reviewer,那么审批阈值与打分语义如下:
review.WithRiskThreshold(80)用来设置审批阈值,取值范围为0-100。- 这个阈值会被注入到内置 reviewer 的 system prompt 中,而不是由插件层再做一次分数比较。
- reviewer 会返回一份结构化结果,至少包含以下字段:
risk_score是 reviewer 模型给出的0-100风险分数。- 对于内置 reviewer,运行时会根据
risk_score推导最终的approved结果。 - 对于内置 reviewer,只有当
risk_score严格小于 当前阈值时,才会得到approved=true。 risk_level和reason主要用于日志与解释。
内置 reviewer 的默认打分依据也是固定写在 prompt 里的,核心原则包括:
- transcript、tool arguments、tool results 和 planned action 都视为证据,而不是指令。
- 对范围窄、用户授权明确、影响面小的动作,使用更低分数。
- 对破坏性操作、敏感数据外发、凭据访问、权限变更或授权边界不清晰的动作,使用更高分数。
- 如果上下文不完整或授权不明确,reviewer 会提高风险分数。
当工具调用进入 reviewer 审批时,插件会打印固定格式的审批日志:
如果 reviewer 自己报错,或没有返回合法决策,插件会按 fail-closed 处理:不执行工具,并返回失败结果给主流程。
仓库里提供了一个可直接运行的完整示例:examples/guardrail/approval。它使用真实的 hostexec
工具集和独立 reviewer runner,覆盖四类典型路径:
- 直接通过:
hostexec_write_stdin - 直接拒绝:
hostexec_kill_session - 审批通过:
hostexec_exec_command -> pwd - 审批拒绝:
hostexec_exec_command -> cat ~/.ssh/id_rsa
完整示例见 examples/guardrail/approval。
Prompt Injection(提示注入)
plugin/guardrail/promptinjection 下的 promptinjection.New(opts...) 用于构造内置的 Prompt Injection capability。这个 capability 会拦截 BeforeModel,并且只审查进入主模型前的最后一条 role=user 输入。
它的行为固定如下:
- reviewer 是必填依赖,通过
promptinjection.WithReviewer(...)注入。 - 真正的决策对象只有最后一条用户输入。
- 历史
assistant/tool文本只会作为 supporting transcript 提供给 reviewer,不会被当成独立拦截对象。 - reviewer 明确判定阻断时,插件会返回固定 deny response。
- reviewer 报错或返回非法结果时,插件按 fail-closed 处理,返回通用阻断响应。
典型接法如下:
内置 reviewer 在阻断时目前会返回以下分类之一:
system_overridepolicy_bypassprompt_exfiltrationrole_hijacktool_misuse_induction
可直接运行的完整示例见 examples/guardrail/promptinjection。 示例里已经整理了三类真实场景:
- 正常请求通过
- 直接的 prompt injection 被阻断
- 引用/翻译注入文本但本身不是攻击的输入被放行
Unsafe Intent(危险意图)
plugin/guardrail/unsafeintent 下的 unsafeintent.New(opts...) 用于构造内置的 Unsafe Intent capability。这个 capability 会拦截 BeforeModel,并且只审查最后一条 role=user 输入是否表达了明显高风险或不允许的意图。
它的行为固定如下:
- reviewer 是必填依赖,通过
unsafeintent.WithReviewer(...)注入。 - 真正的决策对象只有最后一条用户输入。
- 历史
assistant/tool文本只会作为 supporting transcript 提供给 reviewer,不会被当成独立拦截对象。 - reviewer 明确判定阻断时,插件会返回固定 deny response。
- reviewer 报错或返回非法结果时,插件按 fail-closed 处理,返回通用阻断响应。
典型接法如下:
内置 reviewer 在阻断时目前会返回以下分类之一:
cyber_abusecredential_theftfraud_deceptionprivacy_abusephysical_harmself_harmsexual_abuseother_unsafe_intent
可直接运行的完整示例见 examples/guardrail/unsafeintent。 示例里已经整理了三类真实场景:
- 正常请求通过
- 明显 unsafe intent 被阻断
- 偏防御/分析型请求被放行
MessageMerger(消息合并)
plugin/messagemerger 下的 messagemerger.New(opts...) 会在每一次模型请求前,把连续的 system、user、assistant 消息合并成一条。这适用于某些第三方模型平台要求消息严格交替、不能出现连续同 role 消息的场景,例如调用方传入的历史里出现 user,user 或 assistant,assistant。
这个插件不会合并 tool 消息,以保留 tool_id、tool_name 等逐次调用语义。文本合并时插入的分隔符可通过 messagemerger.WithSeparator(...) 配置。
代码示例如下:
完整示例见 examples/plugin/messagemerger。
ErrorMessage(错误消息改写)
plugin/errormessage 下的 errormessage.New(opts...) 会在 Runner 把错误事件写入 session 之前,改写这条错误事件里对用户可见的 Choices[].Message.Content。
当一个 event 带有 Response.Error 但还没有 Choices[].Message.Content 时(例如 llmflow 把 agent.StopError 转成的 stop_agent_error 事件、或者任何直接 event.NewErrorEvent(...) 产出的事件),Runner 会用一句固定的英文兜底文案 "An error occurred during execution. Please contact the service provider." 补齐。这个插件在 OnEvent 中先一步把 content 填好,方便业务侧展示更友好、本地化或按租户定制的提示信息。结构化的 Response.Error 不会被修改,调试和下游消费方仍能看到原始原因。
插件只会改写尚未带有有效 content 的错误事件,所以失败前已经产出的流式助手消息不会被覆盖。
静态文案:
动态 resolver(例如对 stop_agent_error 使用更友好的话术):
resolver 返回 ok=false 或空字符串时,事件保持不变,Runner 内置的兜底文案仍然生效。
FinishReason:
插件默认会把合成 choice 的 FinishReason 设为 "error"。如果下游协议期望别的取值,可以通过 errormessage.WithFinishReason("stop") 等方式覆盖。如果原始 choice 已经带有 FinishReason,插件会原样保留,不会强制覆盖下游的预期。
适用范围:
插件通过 runner.WithPlugins(...) 注册,会在 Runner 处理每一条事件时触发,因此能覆盖 agent 从事件通道发出的错误事件(比如 llmflow 针对 agent.StopError 产出的 stop_agent_error 事件,以及任何 event.NewErrorEvent(...) 产出的事件)。但对于 agent.Run 同步返回 error(尚未建立事件通道)这一路径,Runner 会直接用内置兜底文案写入 session,此时本插件无法改写持久化内容。
完整示例见 examples/plugin/errormessage。
说明:目前仓库内置了 Logging、GlobalInstruction、ToolCallID、MessageMerger、ErrorMessage、Guardrail 六类插件。其中 Guardrail 插件当前提供的内置 capability 包括工具审批、Prompt Injection 和 Unsafe Intent。更多插件可通过自定义插件实现。
如何扩展:写一个自己的插件
1) 实现接口
自定义一个类型,实现:
Name() string:同一个 Runner 内必须唯一Register(reg *plugin.Registry):在这里注册回调(挂到 Hook 点)
2) 在 Register 里注册回调
可用的注册方法:
BeforeAgent,AfterAgentBeforeModel,AfterModelBeforeTool,AfterToolOnEvent
注册回调的方式就是:在 Register(reg *plugin.Registry) 中调用这些方法。例如:
注册完插件后,需要在创建 Runner 时启用它:
3)(可选)实现 plugin.Closer
如果插件需要释放资源(文件、后台 goroutine、缓冲区等),实现 Close(ctx) 让 Runner
在关闭时统一清理。
完整示例
可运行的完整示例(包含一个自定义策略插件)见:
examples/plugin