用 Go 编写原生插件
以原生可加载单元形态用 Go 编写灵渠插件——源码经 go build -buildmode=plugin 产出 .so、被平台同进程加载、零跨边界序列化;含完整最小示例、入站访问控制护栏实战、平台对齐构建、注册加载与失败处置
本页是用 Go 编写灵渠平台插件的权威上手指南,对应 自定义插件开发 §4.1 两种加载形态中的原生可加载单元。读者应已读过 概念与编排(管道骨架、段位、order、短路、流式逐 chunk)与 扩展点 SDK 参考(前置 / 后置对称 Hook 的入参出参契约);本页不重复这些概念,只在需要处一句话回指。
1. 概述与适用场景
原生可加载单元是性能最优的插件形态:Go 源码经 go build -buildmode=plugin 构建为一个 .so 产物,被平台同进程加载,Hook 调用是进程内的直接函数调用,没有跨边界序列化开销。代价是它须与平台运行环境构建参数对齐(运行环境家族、处理器架构、构建工具版本一致),且不支持 Windows、不能交叉编译——.so 须在目标平台上构建。
| 选用 Go 原生形态当…… | 改用沙箱形态当…… |
|---|---|
| 治理逻辑对延迟极敏感,不能容忍跨边界序列化开销 | 需要单一产物跨多种部署环境分发 |
| 部署环境可控,可与平台运行环境构建参数对齐 | 不便逐环境对齐构建,或希望沙箱隔离与内存受限保护 |
| 插件随平台同进程运行、追求最优吞吐 | 目标平台含 Windows,或须跨架构分发同一产物 |
若你的取舍落在右列,请改走 WebAssembly 沙箱路径:见 用 TypeScript 编写插件 与 用 WebAssembly 编写插件。两种形态对外导出的符号语义一致,治理逻辑可在二者间平移。
下图给出 Go 原生插件从源码到纳入管道的完整路径:
2. 前置条件:支持动态加载的构建
以构建产物形态加载外部插件,要求平台实例以支持动态加载的构建运行(默认构建可能不含此能力)。注册原生插件前,先确认当前实例:
curl https://<平台入口地址>/api/v1/deployment \
-H "Cookie: <控制台会话凭证>"响应在部署能力字段中标明动态加载支持状态:
{
"deploy_mode": "private",
"deploy_status": "ready",
"capabilities": {
"dynamic_plugins": true
}
}若
dynamic_plugins为false,先联系部署方切换为支持动态加载的构建,再注册原生插件(详见 自定义插件开发 §4.2)。
原生形态另有一项前置要求:.so 须与平台运行环境的构建参数对齐——同一运行环境家族、同一处理器架构、同一构建工具版本,且 go.mod 中的 SDK 版本与平台运行环境一致。对齐细节见 §7。
3. 项目结构
一个最小的 Go 原生插件就是一个 package main 的模块,导出约定符号,外加一份 go.mod:
access-guard/
├── go.mod # 模块定义 + SDK 依赖(版本须与平台运行环境一致)
├── plugin.go # package main,导出 GetName/Init/PreHook/PostHook/Cleanup
└── Makefile # 可选:封装 go build -buildmode=plugingo.mod 片段:
module access-guard
go 1.22
// SDK 版本须与平台运行环境一致(属于构建参数对齐的一部分,见 §7)
require lingqu.dev/pipeline-sdk v1.4.0原生插件源文件必须是
package main——这是-buildmode=plugin的硬性要求;无需编写main函数,平台按导出符号名识别生命周期回调与 Hook。
4. SDK 接口与符号映射
插件作者从平台提供的扩展点 SDK 包引入类型与常量:
import "lingqu.dev/pipeline-sdk/plugin"Go 用 PascalCase 导出符号(首字母大写才会被导出),逐一映射到 自定义插件开发 §2 的语言中立契约名。可选符号缺失即不挂载对应 Hook(与 自定义插件开发 §1 一致):
| 契约符号(§2 语言中立名) | Go 导出符号 | 签名 | 必选 | 何时调用 |
|---|---|---|---|---|
| get_name | GetName | func GetName() string | 是 | 加载时 |
| init | Init | func Init(cfg plugin.Config) error | 是 | 加载后、首次请求前一次 |
| pre_hook | PreHook | func PreHook(ctx *plugin.HookContext) (*plugin.PreResult, error) | 否 | 每次请求转发上游前 |
| post_hook | PostHook | func PostHook(ctx *plugin.HookContext) (*plugin.PostResult, error) | 否 | 每次上游响应返回后 |
| cleanup | Cleanup | func Cleanup() error | 是 | 卸载 / 停机一次 |
SDK 提供的核心类型按 Hook 契约字段建模,json tag 与 扩展点 SDK §2 的入参出参 JSON 严格一致。HookContext 同时承载前置与后置两阶段上下文,按 Phase 字段区分:
package plugin
// HookContext 承载前置(pre)与后置(post)两阶段的治理上下文。
// Phase 标明当前阶段;按阶段读取对应字段,跨阶段状态走 Context 通道。
type HookContext struct {
Phase string `json:"phase"` // "pre" | "post"
RequestID string `json:"request_id"` // 全流一致(含流式各 chunk)
APIKeyID string `json:"api_key_id"` // 归属子账户标识
Request *Request `json:"request,omitempty"` // pre 阶段
Routing *Routing `json:"routing,omitempty"` // pre 阶段
Response *Response `json:"response,omitempty"` // post 阶段
Usage *Usage `json:"usage,omitempty"` // post 阶段
Stream *Stream `json:"stream,omitempty"` // post 阶段(流式逐 chunk)
Metadata *Metadata `json:"metadata,omitempty"`
Context map[string]any `json:"context,omitempty"` // 前置写入、配对后置回传
}
type Request struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Stream bool `json:"stream"`
}
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
type Routing struct {
Complexity string `json:"complexity"` // 简单 / 复杂
CandidateModel string `json:"candidate_model"` // 路由当前选定上游模型中性名
}
type Response struct {
Model string `json:"model"`
Content string `json:"content"` // 非流式整段;流式为当前 chunk
FinishReason string `json:"finish_reason"`
}
type Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CacheHit bool `json:"cache_hit"` // 上游输入缓存命中(不触发短路)
Thinking bool `json:"thinking"`
PriceTier string `json:"price_tier"` // 6 档之一
}
type Stream struct {
IsStream bool `json:"is_stream"`
ChunkIndex int `json:"chunk_index"`
IsLast bool `json:"is_last"`
}
type Metadata struct {
ReceivedAt string `json:"received_at"`
NodeID string `json:"node_id"`
}前置 / 后置的处置结果与拒因结构:
package plugin
// 前置 Hook 出参:决定请求是否继续、改写、短路或拒绝。
type PreResult struct {
Action string `json:"action"` // 见 Action 常量
Request *Request `json:"request,omitempty"` // Action=ActionRewrite 时
ShortCircuitResponse *Response `json:"short_circuit_response,omitempty"` // Action=ActionShortCircuit 时
RejectReason *Reason `json:"reject_reason,omitempty"` // Action=ActionReject 时
Context map[string]any `json:"context,omitempty"` // 传给配对后置 Hook
}
// 后置 Hook 出参:决定出站响应放行、脱敏或拦截。
type PostResult struct {
Action string `json:"action"` // 见 Action 常量
Response *Response `json:"response,omitempty"` // Action=ActionRedact 时
BlockReason *Reason `json:"block_reason,omitempty"` // Action=ActionBlock 时
}
type Reason struct {
Code string `json:"code"`
Message string `json:"message"`
}
// Config 携带注册时的 config 字段;用 Unmarshal 解析到自定义结构。
type Config struct{ /* SDK 内部持有原始配置 */ }
func (c Config) Unmarshal(v any) error { /* 由 SDK 实现 */ return nil }
// 前置动作常量
const (
ActionContinue = "continue"
ActionRewrite = "rewrite"
ActionShortCircuit = "short_circuit"
ActionReject = "reject"
)
// 后置动作常量
const (
ActionPass = "pass"
ActionRedact = "redact"
ActionBlock = "block"
)各动作的完整语义不在此重复,见 扩展点 SDK §2:前置 continue / rewrite / short_circuit / reject,后置 pass / redact / block。
5. 完整最小示例
先跑通一个"直通"原生插件——它什么也不改,只示意五个导出符号与入参出参的最小形态:前置返回 ActionContinue 并向 context 写一个标记,后置返回 ActionPass 并逆序回收该标记。
package main
import (
"lingqu.dev/pipeline-sdk/plugin"
)
// GetName 返回插件全局唯一标识,须与注册时的 name 一致。
func GetName() string {
return "passthrough-go"
}
// Init 在加载后、首次处理请求前调用一次,接收注册时的 config。
func Init(cfg plugin.Config) error {
// 一次性初始化:解析配置、建立外部连接、前置校验等
return nil
}
// PreHook 入站直通:不改写、不短路,放行后续节点,并向 context 写一个标记。
func PreHook(ctx *plugin.HookContext) (*plugin.PreResult, error) {
return &plugin.PreResult{
Action: plugin.ActionContinue,
Context: map[string]any{
"passthrough_go_seen": ctx.RequestID,
},
}, nil
}
// PostHook 出站直通:放行响应,逆序回收前置写入的 context。
func PostHook(ctx *plugin.HookContext) (*plugin.PostResult, error) {
_ = ctx.Context["passthrough_go_seen"] // 配对前置写入的标记在此回传
return &plugin.PostResult{
Action: plugin.ActionPass,
}, nil
}
// Cleanup 在卸载 / 停机时调用一次,释放资源。
func Cleanup() error {
return nil
}单个插件在一次非流式请求中的调用顺序(与 自定义插件开发 §2 一致):
加载阶段: GetName → Init
每次请求: PreHook →(内建段:上游推理)→ PostHook
卸载阶段: Cleanup直通骨架的价值在于先跑通"加载 → 挂载 → 调用 → 卸载"全链路,再把
Action从ActionContinue/ActionPass逐步改为ActionRewrite/ActionShortCircuit/ActionRedact/ActionBlock,填入真实治理逻辑。流式请求下PreHook仍只调用一次,标记逐 chunk 的PostHook按 chunk 多次调用(见 扩展点 SDK §2.3)。
6. 实战示例:入站访问控制护栏
下面是一个真实治理场景——入站访问控制护栏插件,挂前置段(pre)、fail_policy=fail_closed。PreHook 取末条用户消息内容:命中拦截清单则结构化拒答;命中需剥离的敏感片段则在治理面剥离后继续;都未命中则放行。PostHook 直通收尾。全程不生成任何内容——拒答只是拦截,改写只是按策略剥离片段。
package main
import (
"strings"
"lingqu.dev/pipeline-sdk/plugin"
)
// 注册时通过 config 传入的策略清单。
type guardConfig struct {
BlockKeywords []string `json:"block_keywords"` // 命中即拒绝
RedactKeywords []string `json:"redact_keywords"` // 命中则剥离片段后继续
}
var cfg guardConfig
func GetName() string { return "access-guard" }
func Init(c plugin.Config) error {
// 把注册时的 config 解析进策略结构;解析失败即 Init 失败、插件不纳入编排
return c.Unmarshal(&cfg)
}
// PreHook 入站访问控制:仅在治理面做拦截 / 片段剥离,不生成任何内容。
func PreHook(ctx *plugin.HookContext) (*plugin.PreResult, error) {
last := lastUserContent(ctx)
// (1)命中拦截清单 → 结构化拒答,不再转发上游
for _, kw := range cfg.BlockKeywords {
if kw != "" && strings.Contains(last, kw) {
return &plugin.PreResult{
Action: plugin.ActionReject,
RejectReason: &plugin.Reason{
Code: "guardrail_blocked",
Message: "请求命中访问控制策略,已被入站护栏拦截。",
},
}, nil
}
}
// (2)命中需剥离的敏感片段 → 治理面改写后继续(只剥离片段,不补写语义)
rewritten := last
for _, kw := range cfg.RedactKeywords {
if kw != "" {
rewritten = strings.ReplaceAll(rewritten, kw, "[已按策略剥离]")
}
}
if rewritten != last {
return &plugin.PreResult{
Action: plugin.ActionRewrite,
Request: cloneRequestWithLastUser(ctx, rewritten),
Context: map[string]any{"access_guard": "rewritten"},
}, nil
}
// (3)未命中任何策略 → 放行
return &plugin.PreResult{
Action: plugin.ActionContinue,
Context: map[string]any{"access_guard": "pass"},
}, nil
}
// PostHook 直通收尾:访问控制在前置段已完成,后置仅逆序回收上下文。
func PostHook(ctx *plugin.HookContext) (*plugin.PostResult, error) {
_ = ctx.Context["access_guard"]
return &plugin.PostResult{Action: plugin.ActionPass}, nil
}
func Cleanup() error { return nil }
// 取末条用户消息内容。
func lastUserContent(ctx *plugin.HookContext) string {
if ctx.Request == nil {
return ""
}
for i := len(ctx.Request.Messages) - 1; i >= 0; i-- {
if ctx.Request.Messages[i].Role == "user" {
return ctx.Request.Messages[i].Content
}
}
return ""
}
// 基于入参请求克隆一份、替换末条用户内容,作为改写后的入站请求返回。
func cloneRequestWithLastUser(ctx *plugin.HookContext, content string) *plugin.Request {
src := ctx.Request
msgs := make([]plugin.Message, len(src.Messages))
copy(msgs, src.Messages)
for i := len(msgs) - 1; i >= 0; i-- {
if msgs[i].Role == "user" {
msgs[i].Content = content
break
}
}
return &plugin.Request{Model: src.Model, Messages: msgs, Stream: src.Stream}
}能力边界落地点:
ActionReject只是拦截、ActionRewrite只是按策略剥离片段——二者都不构造替代答案。SDK 不提供内容生成接口,护栏插件无从越界生成(见 §12)。本例挂在前置段,因此访问控制在请求转发上游之前完成;命中拦截时请求根本不会触达上游,节流与治理同时达成。
7. 构建:buildmode=plugin 与平台对齐
用 -buildmode=plugin 构建出 .so:
go build -buildmode=plugin -o access-guard.so .或用一份精简 Makefile:
PLUGIN := access-guard.so
.PHONY: build clean
build:
go build -buildmode=plugin -o $(PLUGIN) .
clean:
rm -f $(PLUGIN)原生形态的可加载性强绑定构建环境,须满足以下对齐约束:
| 约束 | 说明 |
|---|---|
| 在目标平台构建 | .so 须在与平台运行环境相同的 OS / 处理器架构上构建;不可交叉编译 |
| 构建工具版本对齐 | 构建工具版本须与平台运行环境一致 |
| SDK 版本对齐 | go.mod 中 lingqu.dev/pipeline-sdk 的版本须与平台运行环境一致 |
| 不支持 Windows | 原生插件形态不支持 Windows 目标 |
最稳妥的做法是在与平台运行环境一致的容器或主机内执行构建,把上述四项一次性对齐。任一项不一致都会导致加载阶段失败(见 §10、§11)。若部署环境无法逐项对齐,请改走 WebAssembly 沙箱形态——沙箱单一产物对运行环境无对齐要求。
8. 注册与加载
确认 capabilities.dynamic_plugins=true(§2)后,把构建出的 .so 注册到管道。本例挂前置段、护栏类、fail_closed,config 传入策略清单:
curl https://<平台入口地址>/api/v1/pipeline/extensions \
-X POST \
-H "Content-Type: application/json" \
-H "Cookie: <控制台会话凭证>" \
-d '{
"name": "access-guard",
"artifact_ref": "<access-guard.so 的加载位置@版本标识>",
"node_id": "<挂载的安全护栏节点>",
"hook_type": "pre",
"placement": "pre",
"order": 10,
"enabled": true,
"config": {
"block_keywords": ["<拦截关键词1>", "<拦截关键词2>"],
"redact_keywords": ["<待剥离片段1>"]
},
"fail_policy": "fail_closed"
}'| 字段 | 取值要点 |
|---|---|
name | 须与 GetName() 返回值一致(本例 access-guard) |
artifact_ref | 指向 .so 的加载位置,须含版本标识(热替换据此触发,见 §9) |
hook_type / placement | 二者须匹配;前置 Hook 只能落 pre 段(编排约束见 扩展点 SDK §4) |
order | 段位内执行位次,值小者先(前置正序、后置逆序) |
config | 原样传给 Init 的 cfg,本例为策略清单 |
fail_policy | 护栏类默认 fail_closed(见 §10) |
注册后插件即对后续请求生效。读取已注册清单用 GET /api/v1/pipeline/extensions(见 扩展点 SDK §3)。
9. 启停与热替换
- 启用 / 停用:
enabled控制单个插件的执行状态。停用的插件保留在编排清单中、运行时被跳过——便于灰度上线与快速回退,无需注销重注册。 - 位次调整:插件段位内位次随其挂载节点,经
PUT /api/v1/pipeline/order重排(见 概念与编排 §2.2)。 - 热替换:更新
artifact_ref的版本标识即触发平台对该插件重新加载——先以新产物完成Init,再切换执行,旧实例Cleanup收尾,过程不打断在途请求的整体管道。
原生形态的热替换以"整体载入新版本
.so"实现。受运行环境约束,热替换时建议变更产物文件名或版本标识,让新版本以独立产物载入,避免与在驻旧产物冲突。新版本同样须满足 §7 的全部对齐约束。
10. 失败处置
异常按"治理可控、请求可解释"原则处置,策略 fail_policy 随注册声明(fail_open / fail_closed),口径与 扩展点 SDK §5、自定义插件开发 §5.2 一致:
| 情形 | 处置 |
|---|---|
加载 / Init 失败(.so 不兼容、Init 返回 error) | 该插件不纳入编排;其余插件与内建管道照常运行;控制台「中间件编排」页标注其为加载失败 |
PreHook 返回 ActionReject | 请求被结构化拒答,按 RejectReason 返回,不再转发上游;逐条记录护栏处置结果为对应类别 |
PreHook 报错 / panic | 按 fail_policy:fail_open 放行、fail_closed 拒绝;安全护栏类默认 fail_closed |
PostHook 返回 ActionBlock | 出站响应被拦截,按 BlockReason 返回;已发生的计量按实落账 |
PostHook 报错 / panic | 不影响已完成的上游调用与计量落账;按 fail_policy 决定是否放行出站内容,护栏类默认拦截 |
| 短路路径上的后置异常 | 逆序收尾继续执行其余后置 Hook,单个插件异常不阻断整条收尾链 |
护栏类插件一律默认
fail_closed——"宁可拒绝,不放行未经复核的内容"。Hook 内的 panic 由 SDK 收敛,等同于"Hook 执行报错"按fail_policy处置;但由于原生插件与平台同进程,切勿在 Hook 内调用os.Exit、log.Fatal或触发不可恢复的运行环境级操作——它们会影响宿主进程,超出单插件隔离范围。
11. 常见问题
为什么必须在目标平台构建? -buildmode=plugin 的产物与构建环境强绑定,且原生形态不可交叉编译。OS、处理器架构、构建工具版本、SDK 版本任一与平台运行环境不一致,.so 都将无法加载。把构建放在与平台运行环境一致的容器或主机内,是最省心的对齐方式(见 §7)。
.so 与平台版本不一致会怎样? 在加载阶段即失败(对应 §10 失败处置表第一行):该插件不纳入编排、控制台标注加载失败,其余插件与内建管道照常运行,不会连带影响整条管道。解法是用对齐的构建工具版本与 SDK 版本重新构建后再注册。
原生 vs 沙箱怎么选? 治理逻辑对延迟极敏感、且部署环境可控、可逐项对齐构建——选 Go 原生(本页)。需要单一产物跨多种部署环境分发、希望沙箱隔离与内存受限保护、或目标含 Windows——选 WebAssembly 沙箱形态:用 TypeScript 编写插件、用 WebAssembly 编写插件。两种形态导出符号语义一致,逻辑可平移。
只想做后置脱敏,可以不导出 PreHook 吗? 可以。PreHook / PostHook 都是可选符号,缺失即不挂载对应 Hook(见 §4 与 自定义插件开发 §1);GetName / Init / Cleanup 为必选。
12. 能力边界
本页插件同样受灵渠中间件管道的统一能力边界约束,与 扩展点 SDK §6 一致:
插件只在控制面 / 治理面对请求与响应施加处置——校验、路由提示、缓存判定、脱敏、拦截、计量采集——不对结果内容做实质性的生成、加工或合成。内容生成始终由路由选定的上游模型完成。前置的
ActionRewrite与后置的ActionRedact都限于治理面改写(如去除敏感片段、补充结构化路由提示),不构成对生成内容的实质性创作。
对 Go 原生形态尤须强调:与平台同进程不等于可以越界——SDK 不提供内容生成接口,PreResult / PostResult 只承载治理动作与治理面改写后的请求 / 响应字段。一个试图替代上游做内容生成的插件,不符合扩展点 SDK 的能力定义,不应注册。
相关链接
- 概念与编排:三类治理中间件、执行位次、条件分支、短路语义与流式逐 chunk 处理。
- 扩展点 SDK 参考:前置 / 后置对称 Hook 模型、Hook 签名与生命周期、注册启停、编排约束与失败处置、能力边界声明。
- 自定义插件开发:插件模型、生命周期符号、两种加载形态、注册与启停、最小示例契约。
- 用 TypeScript 编写插件:AssemblyScript 编译为 WebAssembly 的沙箱可加载单元路径。
- 用 WebAssembly 编写插件:沙箱可加载单元的通用 host-guest ABI,以 Rust 为主示例(TinyGo / AssemblyScript 同 ABI)。