写在前面
白泽是一个 "旁挂式" 的 AI 助手运行时:单独一个进程,配置放在应用外面,把接口文档变成助手可调用的工具,重要写操作先请人批准,停掉之后业务侧几乎不留痕迹。
一句话定位:它是运行时,不是框架。 这个定位决定了下面所有的架构选择。
一、为什么选 Go
1. 单一二进制、零依赖
旁挂式部署的核心诉求是 "一个进程,拷过去就能跑"。Go 编译出的单二进制文件,不需要目标机器上有解释器、不需要装依赖、不需要虚拟环境。这对要常驻在企业环境里的助手来说,是交付成本最低的形态。
2. goroutine 并发模型
一个助手进程要同时服务多个入口:操作台、带签名的告警 / 工单来信、即时消息渠道。goroutine 让 "一个进程并行处理多个会话" 变得非常自然;工具调用之间的并行(多工具同时执行)在 Go 里也几乎是顺手的事。
3. 跨平台交叉编译
企业环境什么平台都有:Windows、Linux、macOS、ARM。Go 一行 GOOS=linux GOARCH=arm64 go build 就能出目标平台的二进制,不用在目标机器上搭环境。
4. 静态类型 + 工具契约
工具的输入 schema 来自 OpenAPI 文档,映射到 Go 的强类型结构后,很多错误在编译期就被拦下来了。对一个要长时间运行的守护进程来说,这比动态语言省心得多。
二、架构总览
整体是一个清晰的 "核心循环 → 工具路由器 → 执行器" 三层结构:
css
用户 / 渠道 ──► Agent 核心循环(思考 → 选工具 → 执行 → 汇报)
│
▼
工具路由器(Registry)
│
┌────────────────┼────────────────┐
OpenAPI 连接器 HTTP 插件 MCP 连接器
└────────────────┼────────────────┘
▼
Invoker 执行闭包(注册进 Registry)
│
[ HITL 审批门 ]
│
┌─────────────┴─────────────┐
直接执行(插件 / 代理) HTTP 回调执行器(回调企业侧地址)
-
核心循环 (
internal/run):LLM 思考 → 选择工具 → 执行 → 汇报。事件流(llm.thinking、llm.tool_call、tool.result)全程落库,操作台可以边跑边看。 -
工具路由器 (
internal/tool):一个带锁的 map,注册的不是 "函数",而是 "工具契约 + 执行闭包"。 -
执行器 (
internal/connector):工具从三种来源进入 ------OpenAPI 文档、HTTP 插件、MCP 工具服务;其中还有一种 "回调执行" 模式,把执行权交回企业侧。
三、关键设计决策
1. 为什么用 HTTP 回调,而不是插件协议
这是白泽最核心的一个取舍。
插件协议的问题:进程内加载插件(Go plugin、共享库、语言绑定 SDK)要求插件能和宿主进程编译到一起 ------ 语言、版本、ABI 全要对齐。而企业里的系统大多数不是 Go 写的:遗留系统、Java/.NET/Python 服务,进程内插件根本加载不进去。就算加载进去了,升级插件等于重启进程,"旁挂即用、停用干净" 就没了。
HTTP 回调的做法:白泽不直接执行工具,而是把调用信息 POST 到企业自己的地址:
json
{
"tool": "create_ticket",
"arguments": { ... },
"run_id": "run_xxx",
"agent_id": "agent_xxx",
"idempotency_key": "uuid-xxx",
"callback_urls": { "event": "https://your-service/baize-events" }
}
由企业侧执行,再把结果回传。好处是:语言无关、进程隔离、可审计;idempotency_key 幂等键保证网络重试不会重复执行;callback_urls 让企业侧可以继续推进后续动作。
代价:多一次网络往返;回调地址必须可达;为了防止有人伪造回调,需要签名鉴权(白泽用回调签名 + TTL 防重放)。
2. 如何实现工具的动态注册与发现
工具注册表(tool.Registry)是核心数据结构:sync.RWMutex 保护一个 map,支持运行时的注册、注销、按连接器批量注销 ------ 加一个工具、停一个连接器都不用重启进程。
三种工具来源走同一个注册通道:
-
OpenAPI 文档:导入 Swagger/OpenAPI/Postman 文档,每个 operation 变成一个工具;
-
HTTP 插件:一个旁路小服务,按约定声明 "有哪些工具、怎么执行";
-
MCP 工具服务:作为 MCP 客户端连接外部工具生态。
注册时就把安全策略固化进条目:require_approval(需要人批准)、require_login(需要会话登录)、security_schemes(用哪个鉴权方案)。安全策略在注册期决定,而不是执行时临时问------ 这是白泽敢让助手 "干活" 的前提。
工具的发现也很简单:Registry.List() / Registry.Specs() 输出给模型当工具列表,操作台实时可见。
3. 如何保证调用失败时的优雅降级
AI Agent 的失败是常态,所以降级设计比成功路径更重要:
-
超时兜底 :每次工具调用都挂在
context.WithTimeout上,默认 60 秒,可配置; -
失败也是 "内容" :
Invoker返回(content, isError, err)三值 ------err是基础设施故障(超时、网络断了),isError是业务侧失败。两者都作为结构化内容回传给模型,模型可以选择重试、换工具,或者向用户解释,而不是中断整个会话; -
审批拒绝不是崩溃:写操作被人在操作台驳回后,run 进入明确的 "rejected" 终态,事件留痕,而不是抛异常;
-
全程可观测 :
llm.tool_call→tool.result的事件流落库,出问题可以回溯到每一步; -
上下文压缩:长会话自动做滚动摘要,避免上下文爆炸导致质量劣化。
四、与主流方案的对比(Go vs Python / Node.js)
先承认事实:Python 在 AI/Agent 生态上是最好的选择。LangChain、LlamaIndex 这类框架都在 Python 里,模型推理的参考实现也几乎都是 Python。如果目标是快速验证想法、深度复用 LLM 生态,Python 没有对手。
白泽选 Go,是因为它的定位不同:
| 维度 | Go | Python | Node.js |
|---|---|---|---|
| 部署交付 | 单二进制、零依赖 | 解释器 + 依赖安装 / 虚拟环境 | Node 运行时 + node_modules |
| 资源占用 | 低,一个进程常驻无压力 | 偏高,常驻需要额外治理 | 中等 |
| 并发模型 | goroutine 原生并发 | GIL 受限,靠多进程 / 异步 | 事件循环 |
| 类型安全 | 静态类型,编译期检查 | 动态类型,运行时才发现 | 动态 / TypeScript |
| LLM 生态 | 较新,但在快速补齐 | 最丰富 | 丰富 |
| 跨平台 | 交叉编译一键出全平台 | 目标机需装解释器 | 目标机需装 Node |
结论不是 "Go 比 Python 好",而是定位决定语言:
-
目标是 "框架 / 快速实验"→ Python;
-
目标是 "要旁挂、要常驻、要一键部署到企业环境、要在低配机器上长期运行"→ Go 在部署和资源占用上的优势是不可替代的。
五、核心代码片段(Go 实现)
以下代码均来自项目源码,做了精简。每段配一句 "这段在解决什么"。
1. 工具 = 契约 + 执行闭包
把 "工具" 建模成 "给模型看的契约(Spec)+ 由连接器注入的执行闭包(Invoker)",路由和执行完全解耦:
go
type Invoker func(ctx context.Context, args map[string]any) (
content map[string]any, isError bool, err error)
type Meta struct {
Spec llm.ToolSpec
ConnectorID string
Method string
Path string
RequireLogin bool
SecuritySchemes []string
}
2. 运行时动态注册(安全策略随条目固化)
注册时就把 require_approval / require_login 写进条目,工具列表是 "热" 的,加 / 停连接器都不用重启:
yaml
func (r *Registry) RegisterMeta(meta Meta, inv Invoker, requireApproval bool) {
r.mu.Lock()
defer r.mu.Unlock()
r.tools[meta.Spec.Name] = entry{
spec: meta.Spec,
invoker: inv,
requireApproval: requireApproval,
requireLogin: meta.RequireLogin,
connectorID: meta.ConnectorID,
method: meta.Method,
path: meta.Path,
}
}
3. HTTP 回调执行器
把 "执行权" 交回企业侧;幂等键保证网络重试不会重复执行:
go
payload := map[string]any{
"tool": tool,
"arguments": args,
"run_id": meta.RunID,
"agent_id": meta.AgentID,
"idempotency_key": meta.IdempotencyKey,
}
if strings.TrimSpace(meta.CallbackEventURL) != "" {
payload["callback_urls"] = map[string]any{
"event": meta.CallbackEventURL,
}
}
rawPayload, _ := json.Marshal(payload)
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.URL, bytes.NewReader(rawPayload))
4. 写操作自动进审批门
非 GET/HEAD/OPTIONS 的写操作在注册期自动标记 "需审批",由人在操作台点批准 / 驳回后才执行:
ini
needApproval := t.RequireApproval
if ctx.requireApprovalMutating && isMutatingMethod(t.Method) && t.Source == store.ToolSourceSpec {
needApproval = true
}
5. 超时与失败降级
超时兜底 + "失败即内容" 的语义,让一次工具失败不会炸掉整个会话:
go
toolCtx, toolCancel := context.WithTimeout(ctx, e.toolTimeout())
defer toolCancel()
content, isError, invErr := e.Tools.Invoke(toolCtx, payload.ToolName, payload.Arguments)
if invErr != nil {
// 基础设施故障(超时/网络):落库并结束本轮
return e.finalizeFailedRun(runID, invErr)
}
// isError=true 时:失败作为内容回传模型,由模型决定重试或解释
结尾
白泽还在早期阶段,上面这些取舍远没有到 "最优" 的程度,尤其是审批体验、渠道适配、执行器扩展这几个方向,欢迎有真实场景的人来拍砖。
-
仓库:github.com/rebornace/b...(MIT)
-
Issues 里聊聊你的场景和想法,我会持续跟进。