灵渠 LingQu
中间件管道与扩展点 SDK

用 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 原生插件从源码到纳入管道的完整路径:

buildmode plugin 构建 POST extensions 注册 artifact_ref 同进程加载 校验导出符号 按 placement 与 order 纳入 每次请求正序去程 逆序回程 卸载或热替换 Go 源码 package main 原生可加载单元 .so 平台编排面 GetName 与 Init 前置 内建 后置 管道 PreHook 内建 PostHook Cleanup

2. 前置条件:支持动态加载的构建

以构建产物形态加载外部插件,要求平台实例以支持动态加载的构建运行(默认构建可能不含此能力)。注册原生插件前,先确认当前实例:

curl https://<平台入口地>/api/v1/deployment \
  -H "Cookie: <控制台会话凭证>"

响应在部署能力字段中标明动态加载支持状态:

{
  "deploy_mode": "private",
  "deploy_status": "ready",
  "capabilities": {
    "dynamic_plugins": true
  }
}

dynamic_pluginsfalse,先联系部署方切换为支持动态加载的构建,再注册原生插件(详见 自定义插件开发 §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=plugin

go.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_nameGetNamefunc GetName() string加载时
initInitfunc Init(cfg plugin.Config) error加载后、首次请求前一次
pre_hookPreHookfunc PreHook(ctx *plugin.HookContext) (*plugin.PreResult, error)每次请求转发上游前
post_hookPostHookfunc PostHook(ctx *plugin.HookContext) (*plugin.PostResult, error)每次上游响应返回后
cleanupCleanupfunc 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

直通骨架的价值在于先跑通"加载 → 挂载 → 调用 → 卸载"全链路,再把 ActionActionContinue / ActionPass 逐步改为 ActionRewrite / ActionShortCircuit / ActionRedact / ActionBlock,填入真实治理逻辑。流式请求下 PreHook 仍只调用一次,标记逐 chunk 的 PostHook 按 chunk 多次调用(见 扩展点 SDK §2.3)。

6. 实战示例:入站访问控制护栏

下面是一个真实治理场景——入站访问控制护栏插件,挂前置段(pre)、fail_policy=fail_closedPreHook 取末条用户消息内容:命中拦截清单则结构化拒答;命中需剥离的敏感片段则在治理面剥离后继续;都未命中则放行。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.modlingqu.dev/pipeline-sdk 的版本须与平台运行环境一致
不支持 Windows原生插件形态不支持 Windows 目标

最稳妥的做法是在与平台运行环境一致的容器或主机内执行构建,把上述四项一次性对齐。任一项不一致都会导致加载阶段失败(见 §10、§11)。若部署环境无法逐项对齐,请改走 WebAssembly 沙箱形态——沙箱单一产物对运行环境无对齐要求。

8. 注册与加载

确认 capabilities.dynamic_plugins=true(§2)后,把构建出的 .so 注册到管道。本例挂前置段、护栏类、fail_closedconfig 传入策略清单:

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原样传给 Initcfg,本例为策略清单
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 报错 / panicfail_policyfail_open 放行、fail_closed 拒绝;安全护栏类默认 fail_closed
PostHook 返回 ActionBlock出站响应被拦截,按 BlockReason 返回;已发生的计量按实落账
PostHook 报错 / panic不影响已完成的上游调用与计量落账;按 fail_policy 决定是否放行出站内容,护栏类默认拦截
短路路径上的后置异常逆序收尾继续执行其余后置 Hook,单个插件异常不阻断整条收尾链

护栏类插件一律默认 fail_closed——"宁可拒绝,不放行未经复核的内容"。Hook 内的 panic 由 SDK 收敛,等同于"Hook 执行报错"按 fail_policy 处置;但由于原生插件与平台同进程切勿在 Hook 内调用 os.Exitlog.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)。

On this page