把 Agent 接进自己的网站:工具路由器
给一个已有内容的网站(比如知识库 + 工具站)加"AI 助手",最容易踩的坑是照搬通用 Agent 范式: 规划、反思、多轮 ReAct。在真实的小机器上,这套东西会立刻暴露两个问题——慢和不可控。
这篇讲一条更务实的路:工具路由器(tool router)。它不是让模型自由发挥,而是把模型压缩成 一个"决策器",真正干活的是你写好的工具函数。
1. 先算一笔账:为什么不做自主规划
假设你的机器是 2 核 VPS、本地 1.5B 模型、生成速度 2 tok/s。一次自主规划的问答:
规划(Plan) → 200 token ≈ 100s
工具调用(Action) → 1~3 次,每次再 100~200 token
观察(Observe) → 拼上下文
反思(Reflect) → 再来一轮 100s
最终成文(Answer) → 300 token ≈ 150s
一次回答 5~8 分钟,而且任何一轮输出格式跑偏,整条链路就崩。小模型的 JSON 遵循能力本来就弱, 轮次越多、失败率越接近 1。
工具路由器把它压到 2~3 次模型调用:
第 1 轮:{"tool":"kb_search","args":{"q":"goroutine 调度"}} ← 只输出一个 JSON,max_tokens=160
↓ 执行工具(Go 代码,毫秒级)
第 2 轮:{"final":"..."} 或再调一个工具
↓
成文轮(流式):把工具结果拼进上下文,让模型写成自然语言
决策轮只输出结构化数据、不生成自然语言,这是它能省时间的关键:160 token 的 JSON, 2 tok/s 也就 20 多秒。
2. 协议怎么定:一个 JSON,三种形态
给模型一个极简协议,比给一堆 function-calling 的 schema 更可靠:
需要信息:{"tool":"<工具名>","args":{...}}
可以直接回答:{"final":"<自然语言>"}
配套三条工程约定:
- 容忍围栏:模型常把 JSON 包在
json里,解析前先截取第一个{到最后一个}; - final 必须是自然语言:如果
final的值又是 JSON(小模型很爱这样),判为协议错误丢弃; - 未知工具不报错:把"工具不存在"作为观察回喂给模型,让它重选,而不是让请求失败。
type action struct {
Tool string `json:"tool"`
Args map[string]any `json:"args"`
Final string `json:"final"`
}
func parseAction(raw string) (action, bool) {
s := strings.TrimSpace(raw)
if i := strings.Index(s, "{"); i >= 0 {
if j := strings.LastIndex(s, "}"); j > i {
s = s[i : j+1]
}
}
var a action
if err := json.Unmarshal([]byte(s), &a); err != nil {
return action{}, false
}
if a.Tool == "" && strings.TrimSpace(a.Final) == "" {
return action{}, false
}
return a, true
}
3. 工具注册表:让"能力"可枚举、可展示
工具不要散落在 handler 里。定义一个注册表,每个工具四项:名字、参数说明、描述、执行函数。
type Tool struct {
Name string
Desc string
Params string // 进系统提示词
Run func(ctx context.Context, args map[string]any) (Result, error)
}
type Result struct {
Text string // 给模型读
Refs []Ref // 给用户看(来源卡片)
}
Result 分成两部分是刻意的:模型只需要 Text,用户需要 Refs。很多实现把两者混在一起,
结果要么模型读到一堆链接、要么用户看不到出处。
注册表还顺手解决了两件事:
- 前端能展示"它能干什么":
GET /agent/meta直接把工具清单吐给页面; - 提示词可以自动生成:
Catalog()遍历注册表生成工具目录,加工具不用改提示词。
4. 一次问答的事件流
后端用 SSE(text/event-stream)推事件,前端逐条渲染:
{"type":"meta","text":"qwen"} // 开始,带上模型名
{"type":"tool","tool":"kb_search","args":{...},"text":"...","refs":[...]} // 调了什么、拿到什么
{"type":"delta","text":"Go 的调度"} // 回答片段(流式)
{"type":"done","text":"完整回答","refs":[...]} // 结束 + 来源
{"type":"error","err":"..."} // 出错
好处是**"正在干什么"对用户可见**:先看到"🔧 kb_search(...)",再看到文字一个个冒出来, 等待的焦虑感会低很多——这在慢模型上尤其重要。
5. 兜底:模型不配合时也不能空手而归
路由器必须有降级路径,否则一次协议漂移就是一次"助手坏了":
决策轮解析失败?
├─ 有工具结果 → 直接用结果成文
├─ 没有结果 → 拿用户问题直接检索知识库,用检索结果成文
└─ 还是不行 → 返回"没理解,换个说法"
这条链里最值钱的是第二档:即使模型的决策能力完全失效,"搜一下知识库再回答"也能给出有用输出。 它本质上退化成了 RAG,而这恰好覆盖了大部分"问站里内容"的需求。
6. 最小实现清单
| 组件 | 职责 | 代码量 |
|---|---|---|
| LLM 客户端 | OpenAI 兼容,非流式 + SSE 流式 | ~150 行 |
| 工具注册表 | 工具定义、目录生成、知识库/工具/博客封装 | ~300 行 |
| 编排器 | 决策循环、解析、兜底、成文 | ~200 行 |
| HTTP 层 | SSE 输出、配额、并发闸门 | ~120 行 |
| 前端 | 输入框 + 消息列表 + SSE 解析 + 工具/来源渲染 | ~250 行 |
不到 1000 行就能把一个"能查知识库、能调工具"的助手接进网站。真正难的不是代码量, 是第 3 节讲的注册表抽象和下面这篇要讲的坑。
小结
工具路由器的本质是把不确定性关进笼子:模型只在"选哪个工具"这个极小的决策空间里输出结构化数据, 其余全部由确定性代码完成。它牺牲了"自主规划"的灵活性,换来的是可预测的延迟、可枚举的能力、 以及一条永远能工作的兜底路径——对小机器、小模型来说,这笔交易非常划算。