用 Go 实现轻量级 AI Agent:白泽的架构设计与取舍

文章来源声明: 原文作者:rebornace; 来源站点:掘金; 原文链接:https://juejin.cn/post/7686070416848748594; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

需要旁挂常驻、一键交付企业内网、低配机器长期运行的团队,可重点参考其 HTTP 回调与热注册设计;若追求快速验证、复用 LLM 生态,Python 仍更划算。

---

写在前面

白泽是一个 "旁挂式" 的 AI 助手运行时:单独一个进程,配置放在应用外面,把接口文档变成助手可调用的工具,重要写操作先请人批准,停掉之后业务侧几乎不留痕迹。

一句话定位:它是运行时,不是框架。 这个定位决定了下面所有的架构选择。


一、为什么选 Go

1. 单一二进制、零依赖

旁挂式部署的核心诉求是 "一个进程,拷过去就能跑"。Go 编译出的单二进制文件,不需要目标机器上有解释器、不需要装依赖、不需要虚拟环境。这对要常驻在企业环境里的助手来说,是交付成本最低的形态。

2. goroutine 并发模型

一个助手进程要同时服务多个入口:操作台、带签名的告警 / 工单来信、即时消息渠道。goroutine 让 "一个进程并行处理多个会话" 变得非常自然;工具调用之间的并行(多工具同时执行)在 Go 里也几乎是顺手的事。

3. 跨平台交叉编译

企业环境什么平台都有:Windows、Linux、macOS、ARM。Go 一行 GOOS=linux GOARCH=arm64 go build 就能出目标平台的二进制,不用在目标机器上搭环境。

4. 静态类型 + 工具契约

工具的输入 schema 来自 OpenAPI 文档,映射到 Go 的强类型结构后,很多错误在编译期就被拦下来了。对一个要长时间运行的守护进程来说,这比动态语言省心得多。


二、架构总览

整体是一个清晰的 "核心循环 → 工具路由器 → 执行器" 三层结构:

用户 / 渠道 ──► Agent 核心循环(思考 → 选工具 → 执行 → 汇报)
                        │
                        ▼
                 工具路由器(Registry)
                        │
       ┌────────────────┼────────────────┐
  OpenAPI 连接器      HTTP 插件        MCP 连接器
       └────────────────┼────────────────┘
                        ▼
             Invoker 执行闭包(注册进 Registry)
                        │
                   <span>[ HITL 审批门 ]</span>
                        │
          ┌─────────────┴─────────────┐
     直接执行(插件 / 代理)      HTTP 回调执行器(回调企业侧地址)

  • 核心循环internal/run):LLM 思考 → 选择工具 → 执行 → 汇报。事件流(llm.thinkingllm.tool_calltool.result)全程落库,操作台可以边跑边看。
  • 工具路由器internal/tool):一个带锁的 map,注册的不是 "函数",而是 "工具契约 + 执行闭包"。
  • 执行器internal/connector):工具从三种来源进入 ——OpenAPI 文档、HTTP 插件、MCP 工具服务;其中还有一种 "回调执行" 模式,把执行权交回企业侧。

三、关键设计决策

1. 为什么用 HTTP 回调,而不是插件协议

这是白泽最核心的一个取舍。

插件协议的问题:进程内加载插件(Go plugin、共享库、语言绑定 SDK)要求插件能和宿主进程编译到一起 —— 语言、版本、ABI 全要对齐。而企业里的系统大多数不是 Go 写的:遗留系统、Java/.NET/Python 服务,进程内插件根本加载不进去。就算加载进去了,升级插件等于重启进程,"旁挂即用、停用干净" 就没了。

HTTP 回调的做法:白泽不直接执行工具,而是把调用信息 POST 到企业自己的地址:

<span>{</span>
  <span>"tool"</span><span>:</span> <span>"create_ticket"</span><span>,</span>
  <span>"arguments"</span><span>:</span> <span>{</span> ... <span>}</span><span>,</span>
  <span>"run_id"</span><span>:</span> <span>"run_xxx"</span><span>,</span>
  <span>"agent_id"</span><span>:</span> <span>"agent_xxx"</span><span>,</span>
  <span>"idempotency_key"</span><span>:</span> <span>"uuid-xxx"</span><span>,</span>
  <span>"callback_urls"</span><span>:</span> <span>{</span> <span>"event"</span><span>:</span> <span>"https://your-service/baize-events"</span> <span>}</span>
<span>}</span>

由企业侧执行,再把结果回传。好处是:语言无关、进程隔离、可审计;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_calltool.result 的事件流落库,出问题可以回溯到每一步;
  • 上下文压缩:长会话自动做滚动摘要,避免上下文爆炸导致质量劣化。

四、与主流方案的对比(Go vs Python / Node.js)

先承认事实:Python 在 AI/Agent 生态上是最好的选择。LangChain、LlamaIndex 这类框架都在 Python 里,模型推理的参考实现也几乎都是 Python。如果目标是快速验证想法、深度复用 LLM 生态,Python 没有对手。

白泽选 Go,是因为它的定位不同:

维度GoPythonNode.js
部署交付单二进制、零依赖解释器 + 依赖安装 / 虚拟环境Node 运行时 + node\_modules
资源占用低,一个进程常驻无压力偏高,常驻需要额外治理中等
并发模型goroutine 原生并发GIL 受限,靠多进程 / 异步事件循环
类型安全静态类型,编译期检查动态类型,运行时才发现动态 / TypeScript
LLM 生态较新,但在快速补齐最丰富丰富
跨平台交叉编译一键出全平台目标机需装解释器目标机需装 Node

结论不是 "Go 比 Python 好",而是定位决定语言

  • 目标是 "框架 / 快速实验"→ Python;
  • 目标是 "要旁挂、要常驻、要一键部署到企业环境、要在低配机器上长期运行"→ Go 在部署和资源占用上的优势是不可替代的。

五、核心代码片段(Go 实现)

以下代码均来自项目源码,做了精简。每段配一句 "这段在解决什么"。

1. 工具 = 契约 + 执行闭包

把 "工具" 建模成 "给模型看的契约(Spec)+ 由连接器注入的执行闭包(Invoker)",路由和执行完全解耦:

<span>type</span> Invoker <span><span>func</span><span>(ctx context.Context, args <span>map</span>[<span>string</span>]any)</span></span> (
    content <span>map</span>[<span>string</span>]any, isError <span>bool</span>, err <span>error</span>)
<span>type</span> Meta <span>struct</span> {
    Spec            llm.ToolSpec
    ConnectorID     <span>string</span>
    Method          <span>string</span>
    Path            <span>string</span>
    RequireLogin    <span>bool</span>
    SecuritySchemes []<span>string</span>
}

2. 运行时动态注册(安全策略随条目固化)

注册时就把 require_approval / require_login 写进条目,工具列表是 "热" 的,加 / 停连接器都不用重启:

<span>func</span> <span>(r</span> <span>*Registry)</span> <span>RegisterMeta(meta</span> <span>Meta,</span> <span>inv</span> <span>Invoker,</span> <span>requireApproval</span> <span>bool)</span> {
    <span>r.mu.Lock()</span>
    <span>defer</span> <span>r.mu.Unlock()</span>
    <span>r.tools</span>[<span>meta.Spec.Name</span>] <span>=</span> <span>entry</span>{
        <span>spec:</span>            <span>meta.Spec</span>,
        <span>invoker:</span>         <span>inv</span>,
        <span>requireApproval:</span> <span>requireApproval</span>,
        <span>requireLogin:</span>    <span>meta.RequireLogin</span>,
        <span>connectorID:</span>     <span>meta.ConnectorID</span>,
        <span>method:</span>          <span>meta.Method</span>,
        <span>path:</span>            <span>meta.Path</span>,
    }
}

3. HTTP 回调执行器

把 "执行权" 交回企业侧;幂等键保证网络重试不会重复执行:

payload := <span>map</span>[<span>string</span>]any{
    <span>"tool"</span>:            tool,
    <span>"arguments"</span>:       args,
    <span>"run_id"</span>:          meta.RunID,
    <span>"agent_id"</span>:        meta.AgentID,
    <span>"idempotency_key"</span>: meta.IdempotencyKey,
}
<span>if</span> strings.TrimSpace(meta.CallbackEventURL) != <span>""</span> {
    payload[<span>"callback_urls"</span>] = <span>map</span>[<span>string</span>]any{
        <span>"event"</span>: meta.CallbackEventURL,
    }
}
rawPayload, _ := json.Marshal(payload)
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.URL, bytes.NewReader(rawPayload))

4. 写操作自动进审批门

非 GET/HEAD/OPTIONS 的写操作在注册期自动标记 "需审批",由人在操作台点批准 / 驳回后才执行:

needApproval := t.RequireApproval
if ctx.requireApprovalMutating && isMutatingMethod(t.Method) && <span>t.Source</span> == store.ToolSourceSpec {
    <span>needApproval</span> = <span>true</span>
}

5. 超时与失败降级

超时兜底 + "失败即内容" 的语义,让一次工具失败不会炸掉整个会话:

toolCtx, toolCancel := context.WithTimeout(ctx, e.toolTimeout())
<span>defer</span> toolCancel()
content, isError, invErr := e.Tools.Invoke(toolCtx, payload.ToolName, payload.Arguments)
<span>if</span> invErr != <span>nil</span> {
    <span>// 基础设施故障(超时/网络):落库并结束本轮</span>
    <span>return</span> e.finalizeFailedRun(runID, invErr)
}
<span>// isError=true 时:失败作为内容回传模型,由模型决定重试或解释</span>


结尾

白泽还在早期阶段,上面这些取舍远没有到 "最优" 的程度,尤其是审批体验、渠道适配、执行器扩展这几个方向,欢迎有真实场景的人来拍砖。