把 Agent 接进自己的网站:工具路由器

#Agent实战

把 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":"<自然语言>"}

配套三条工程约定:

  1. 容忍围栏:模型常把 JSON 包在 json 里,解析前先截取第一个 { 到最后一个 };
  2. final 必须是自然语言:如果 final 的值又是 JSON(小模型很爱这样),判为协议错误丢弃;
  3. 未知工具不报错:把"工具不存在"作为观察回喂给模型,让它重选,而不是让请求失败。
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 节讲的注册表抽象和下面这篇要讲的坑。

小结

工具路由器的本质是把不确定性关进笼子:模型只在"选哪个工具"这个极小的决策空间里输出结构化数据, 其余全部由确定性代码完成。它牺牲了"自主规划"的灵活性,换来的是可预测的延迟、可枚举的能力、 以及一条永远能工作的兜底路径——对小机器、小模型来说,这笔交易非常划算。

笔记加载中…