Saber工具调用全流程
前置知识1.聊聊MCP全文概要这一章把 Saber 项目"如何让 LLM 调工具"讲清楚。读完后,你应该能回答:项目有没有 function calling、它是怎么实现的、为什么这么实现、RAG 怎么变成一个 tool 被 LLM 调用、整个流程从 query 到答案中间发生了什么。5 分钟...
字数: 5930 | 语雀原文
前置知识
1.聊聊MCP
全文概要
这一章把 Saber 项目"如何让 LLM 调工具"讲清楚。读完后,你应该能回答:项目有没有 function calling、它是怎么实现的、为什么这么实现、RAG 怎么变成一个 tool 被 LLM 调用、整个流程从 query 到答案中间发生了什么。
5 分钟先建立直觉
LLM 是一颗"装在玻璃罐里的大脑"——它只会基于训练数据吐文本,不能:
- 查实时天气
- 跑 Linux 命令
- 读你电脑上的文件
- 检索你的私人知识库
但用户问的问题往往就需要这些能力。怎么办?给它一组"机械臂",让它告诉系统"我要调用
get_weather('北京')",系统替它调,把结果塞回去给它继续说。
这就是 Tool Calling(工具调用),也叫 Function Calling。
📦 额外知识:Tool Calling 的两种风格
- **原生 **function calling:OpenAI / Anthropic 在 API 层提供
tools字段,LLM 输出结构化tool_callsJSON。优点:稳定,模型专门训练过。缺点:协议绑死、表达能力受限。 - 自实现 JSON 风格(本项目):把 tools 描述拼进 prompt,让 LLM 吐 JSON,自己解析。优点:换 LLM 不用改协议、能塞
depends_on / race_group这种私货。缺点:吃 prompt token、需要做大量 fallback。 Saber 走的是第二条路。下面顺着代码讲清楚为什么。
1. 整张工具调用全景图
用户 query
│
▼
┌───────────────────────────────────────┐
│ Router 路由决策 (infra_router.go) │
│ ┌─ chat 纯对话 │
│ ├─ tool 单工具 (Decide 关键词) │
│ ├─ rag 知识库检索 │
│ └─ react 多工具 DAG 规划 ★最复杂 │
└───────────────┬───────────────────────┘
│
┌───────────────┼───────────────────────┐
│ │ │
▼ ▼ ▼
runTool runRAG runReAct
单步执行 直接查库 ★ 多步规划 + DAG 并行 + 竞速
│
▼
Planner LLM
(plan_graph.go)
│
▼
JSON 解析 + 校验
│
▼
GraphRuntime
(runtime_graph.go)
│
┌─────┴─────┐
▼ ▼
拓扑分层 RaceGroup 竞速
│ │
└─────┬─────┘
▼
tool.Execute(params)
│
▼
Generator LLM
(mode_react.go llmGenerate)
│
▼
最终答案
image.png
下面分 6 章一步步讲解。
2. 第 1 步:工具是什么 —— Tool 数据结构
最底层抽象在 internal/domain/tool/tool.go:
type Tool struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters []Param `json:"parameters"`
IsMCP bool `json:"is_mcp,omitempty"`
Execute func(params map[string]interface{}) (string, error) `json:"-"`
}
类比:一个 Tool 就是一张"名片 + 一个按钮":
- 名片:
Name + Description + Parameters给 LLM 看("我叫 get_weather,能查天气,需要 city 参数") - 按钮:
Execute给系统按(真正执行) 注意json:"-":Execute 是 Go 函数指针,不能也不应该序列化给 LLM。LLM 只能看到名片决定调用,按钮永远在本地系统里。
Param 结构:
type Param struct {
Name string
Type string // "string" / "boolean" / "int" 等
Description string
Required bool
}
📦 额外知识:为什么 params 是 **map[string]interface{}** 不是强类型 struct?
因为工具种类无穷多——get_weather 要 city,exec_command 要 command,没法用一个统一 struct。interface{} + map 是 Go 实现"动态参数表"的标准模式,代价是 Execute 内部要手动 type assert(p["city"].(string)),但灵活性换来了"加一个工具不用改框架"。
3. 第 2 步:工具放在哪 —— 并发安全的 toolRegistry
LLM 可能同时被多路调用(HTTP 接口、SSE 流、ReAct 循环),它们都要读工具表。Go 原生 map 并发读写直接 panic,所以包装一层。看 internal/application/chat/tool_registry.go:
type toolRegistry struct {
mu sync.RWMutex
tools map[string]tool.Tool
}
三个核心方法:
| 方法 | 何时调用 | 锁 |
|---|---|---|
register |
Agent 初始化注册 / 动态加 MCP | 写锁 |
snapshot |
每次 query 拿一份 map 浅拷贝供后续用 | 读锁 |
filter |
前端勾选了部分工具时按白名单筛 | 读锁 |
为什么用 snapshot 而不是直接共享 map?
func (r *toolRegistry) snapshot() map[string]tool.Tool {
r.mu.RLock()
defer r.mu.RUnlock()
cp := make(map[string]tool.Tool, len(r.tools))
for k, v := range r.tools {
cp[k] = v
}
return cp
}
调用方拿到 snapshot 后解锁即返回,后续遍历 map 无需持锁——避免 ReAct 循环(可能跑十几秒)一直握着读锁,挡住 MCP 工具动态注册。这是个标准的**"读时拷贝、写时上锁"模式**。
💡 一句话记住:Tool 是"名片 + 按钮"。toolRegistry 用 RWMutex 包 map 提供 snapshot,调用方拿快照后无锁遍历,避免长任务挡住注册。
4. 第 3 步:路由决策 —— 4 种模式分发
不是所有 query 都需要走工具调用。runtime_process.go 的 routeDecide 分两条路径:
路径 A:前端显式指定(最准确)
if opts.Explicit {
switch {
case len(opts.SelectedTools) > 0:
routeTools = a.filterTools(opts.SelectedTools)
if a.needReActFromTools(query, routeTools) {
mode = "react"
} else {
mode = "tool"
}
case opts.UseRAG && a.rag.Loaded:
mode = "rag"
default:
mode = "chat"
}
return
}
前端勾了"使用工具 search_web + rag_search" → 直接走 react 模式。这是最干净的路径,因为用户明示了。
路径 B:关键词启发式(自动)
switch {
case a.needReAct(query):
mode = "react"
case a.needTool(query):
mode = "tool"
case a.needRAG(query):
mode = "rag"
default:
mode = "chat"
}
具体规则在 infra_router.go:
func (a *UnifiedAgent) needReAct(query string) bool {
q := strings.ToLower(query)
// "写报告 / 生成方案 / 总结文档" → react
if (含"报告"|"文档"|"方案") && (含"生成"|"写"|"总结"|"保存") { return true }
// "调研 / 研究" → react
if 含"调研"|"研究" { return true }
// 含 2+ 子需求("查天气和时间")→ react
count := 0
if 含"时间" { count++ }
if 含"天气" { count++ }
...
return count >= 2
}
为什么是关键词不是 LLM? 路由是入口,要快、便宜、确定。LLM 路由要再多花一次 API 调用 + 1-2 秒延迟。关键词错了大不了走错路径,错路径里还有降级。
📦 额外知识:为什么 RAG 是 "needTool / needReAct 都不命中" 才走?
a.rag.Loaded && !a.needTool(query) && !a.needReAct(query)
这是个优先级设计:如果 query 明显是问天气 / 时间,没必要先去 RAG 翻一遍知识库;如果是复合任务(写报告),ReAct 模式里 Planner 会自动把 rag_search 排进图里,不必单独走 RAG 模式。RAG 模式只服务"看起来像问私人知识库的纯检索问题"。
5. 第 4 步:单工具模式 —— 最简单的路径
走 mode=tool 时调 mode_tool.go,整段不到 40 行:
func (a *UnifiedAgent) runTool(...) (string, *tool.CallResult) {
// ① 工具选择(纯规则,不调 LLM)
tc := tool.Decide(query, ts)
if tc == nil { return "我无法处理这个请求。", nil }
// ② 取出工具
t, ok := ts[tc.ToolName]
if !ok { return fmt.Sprintf("工具 %s 不存在", tc.ToolName), tc }
// ③ 偏好填充(自动把"北京"塞到 city 参数)
a.fillParamsFromPreference(tc)
// ④ 执行
result, err := t.Execute(tc.Params)
// ⑤ 推 SSE 事件给前端
emit(onEvent, "tool_call", map[string]interface{}{
"tool_name": tc.ToolName, "params": tc.Params, "tool_result": result,
})
// ⑥ LLM 综合 query + 工具结果生成自然语言答案
systemPrompt := ... "你是一个善于综合信息的AI助手" ...
userMsg := fmt.Sprintf("用户问:%s\n工具 %s 返回结果:%s\n请根据结果自然地回答用户。", query, tc.ToolName, result)
answer := a.chatLLM(ctx, systemPrompt, []llm.Message{{Role: "user", Content: userMsg}}, onEvent)
return answer, tc
}
tool.Decide 是纯关键词选工具:
if strings.Contains(q, "几点") || strings.Contains(q, "时间") {
if _, ok := ts["get_time"]; ok {
return &CallResult{ToolName: "get_time", Params: ...}
}
}
if strings.Contains(q, "天气") { ... }
if strings.Contains(q, "查") || ... { ... }
核心权衡:单工具模式整体只调 1 次 LLM(最后那次综合),延迟低;代价是工具选择是规则的,不够智能。"查天气"能命中,"我女朋友所在城市今天会下雨吗"就只能走规则兜底(取首个工具)或干脆走不通。
💡 一句话记住:单工具 = 1 次规则选工具 + 1 次工具执行 + 1 次 LLM 综合。简单、快、便宜,适合明确的单步任务。
6. 第 5 步:ReAct 模式 —— 全套工具调用流程 ★ 核心
mode=react 时调 mode_react.go 的 runReAct,分 5 步:
Step 1: Planner LLM 决定调谁
planNodes := a.llmPlanGraph(ctx, query, ts, memPrefix)
if len(planNodes) == 0 {
// 不需要工具,直接 LLM 对话
...
}
llmPlanGraph 做的事(plan_graph.go):
// ① 拼工具描述到 prompt(自实现 function calling 的关键)
for name, t := range ts {
pDescs := /* 拼参数 */
toolLines = append(toolLines, fmt.Sprintf("- %s: %s [参数: %s]", name, t.Description, params))
}
planPrompt := `你是一个任务规划器...
规则:
- 给每个节点分配一个唯一 id(如 n1, n2, n3...)
- type 只能是 "tool" 或 "sub_agent"
- 如果节点 B 需要节点 A 的输出,则 B 的 depends_on 包含 A 的 id
- 如果两个工具功能类似(如多个搜索源),设相同的 race_group...
可用工具:%s
可用子 Agent:%s
请以 JSON 数组格式输出执行计划:
[{"id":"n1","type":"sub_agent","agent":"research_agent",...,"depends_on":[],"race_group":""}]
如果无需工具直接回答,输出 []。只输出 JSON,不要其他内容。`
raw := a.llm.ChatContext(ctx, plannerBase, ...)
// ② 多档 schema 解析(容忍 LLM 输出漂移)
var nodes []planNode
if err := json.Unmarshal([]byte(raw), &nodes); err != nil {
// 降级 1:旧格式
// 降级 2:原生 function-calling 格式
// 全失败:走 rulePlanNodes 关键词规则
}
// ③ 业务校验
for _, n := range nodes {
// 工具不存在 → 丢弃
if _, ok := ts[n.Tool]; !ok { continue }
// 补全 id / depends_on / params 等
}
📦 额外知识:LLM 输出不稳定怎么办?6 道防线
- Prompt** 工程**:枚举可选值、给示例、约束"只输出 JSON"
- 输出清洗:剥 ```json 包裹、剥
<|FunctionCallBegin|>特殊 token - 三档 schema 解析:理想格式 → 旧格式 → 原生 function calling 格式
- 白名单过滤:编出来的不存在工具直接丢
- 整体降级:解析全失败 → 走纯规则
rulePlanNodes - 图层兜底:DAG 有环 → 清空依赖降级全并行 这是工程师答 "LLM 输出不可靠你怎么处理" 的标准范式。
Step 2: 构建 TaskGraph
tg := graph.NewTaskGraph(planNodes)
if err := tg.Validate(); err != nil {
// 图校验失败(有环、id 重复等),降级为全并行
for _, n := range planNodes { n.DependsOn = nil }
tg = graph.NewTaskGraph(planNodes)
}
TaskGraph 做两件事:
- 拓扑排序:把节点分成层,同层节点可以并行
- 校验:检测环、检测 depends_on 指向不存在的节点 📦 额外知识:什么是 DAG(有向无环图)?
全称 Directed Acyclic Graph。"有向"= A→B 不等于 B→A,"无环"= 没有 A→B→A 这种循环。任务依赖天然是 DAG —— "先调研后写报告" 就是 research_agent → writer_agent。拓扑排序就是 "把 DAG 按依赖顺序排成一串"。
Step 3: GraphRuntime 并行调度
gcfg := GraphConfig{
MaxParallel: a.cfg.GraphMaxParallel,
RaceTimeoutMs: a.cfg.GraphRaceTimeoutMs,
EnableRacing: a.cfg.GraphEnableRacing,
}
rt := NewGraphRuntime(tg, a, gcfg, ts, task, onEvent)
graphResult := rt.Execute(ctx)
Execute 逐层调度:
for levelIdx, level := range levels {
// 按 race_group 分组
groups := rt.groupByRace(level)
var wg sync.WaitGroup
for _, g := range groups {
if g.RaceGroup != "" && rt.cfg.EnableRacing {
// 同 race_group → 竞速
wg.Add(1)
go rt.raceGroup(ctx, g, &wg)
} else {
// 独立节点 → 直接并行
for _, nodeID := range g.NodeIDs {
wg.Add(1)
go rt.executeNode(ctx, nodeID, &wg)
}
}
}
wg.Wait()
// 持久化快照(中断恢复用)
rt.agent.saveSnapshot(rt.task)
}
并发控制有 3 个旋钮:
| 配置 | 含义 |
|---|---|
MaxParallel |
信号量 chan struct{},限制最大同时跑几个 |
RaceTimeoutMs |
竞速组超时 |
EnableRacing |
是否启用竞速 |
Step 4: 竞速执行(First-success-wins)★
这是 ReAct 模式的杀器。
func (rt *GraphRuntime) raceGroup(ctx context.Context, g raceGroup, wg *sync.WaitGroup) {
ch := make(chan raceResult, len(g.NodeIDs))
raceCtx, cancel := context.WithCancel(ctx)
defer cancel()
// 并行启动所有竞速节点
for _, nodeID := range g.NodeIDs {
go func(id graph.NodeID) {
rt.sem
### 7. 第 6 步:执行单节点 —— 重试 + 中断 + 事件推送
`executeSingleNode` 是真正"按按钮"的地方:
func (rt *GraphRuntime) executeSingleNode(ctx context.Context, nodeID graph.NodeID) (string, error) { node := rt.graph.Nodes[nodeID]
// ① 推送 Thought / Action 事件给前端
if rt.onEvent != nil {
rt.onEvent(NewStreamEvent("step", thoughtStep))
rt.onEvent(NewStreamEvent("step", actionStep))
}
// ② 准备 run 函数(支持 tool 和 sub_agent 两种类型)
run := func() (string, error) {
if node.Type == graph.NodeTypeSubAgent {
sa, _ := rt.agent.subagents.get(node.AgentName)
return sa.Run(ctx, SubAgentTask{...})
}
t, ok := rt.tools[node.ToolName]
if !ok { return "", fmt.Errorf("工具 %s 不在允许列表中", node.ToolName) }
return t.Execute(params) // ★ 这里真正执行 tool
}
// ③ 带重试的执行
for attempt := 0; attempt
8. RAG 当作 Tool 的特殊之处 ★
前面是工具调用的"通用骨架"。RAG 作为 5 个工具中的一员,有 5 个和其他工具不一样的地方。
特殊点 1:注册位置不在 builtin
普通工具如 get_time / get_weather / search_web 在 internal/infrastructure/tool/builtin.go 的 DefaultTools() 静态注册:
func DefaultTools() map[string]tool.Tool {
list := []tool.Tool{GetTime(), GetWeather(), SearchWeb()}
...
}
RAG 工具不在这里。看 core_agent.go 的 registerBuiltinTools:
func (a *UnifiedAgent) registerBuiltinTools() {
a.RegisterTool(tool.Tool{
Name: "rag_search",
...
Execute: func(params map[string]interface{}) (string, error) {
...
answer, _ := a.rag.Query(q) // ← 闭包捕获 a.rag
return answer, nil
},
})
...
a.registerDocumentTools()
}
为什么不能放 builtin? 因为 Execute 是个闭包,**捕获了 ****a.rag** 这个 RAG Engine 实例。builtin 是 infrastructure 层的纯静态工具,拿不到 Engine 实例。所以 RAG 工具必须在 application 层(agent 初始化时)注册。
特殊点 2:依赖 Loaded 状态做前置检查
Execute: func(params map[string]interface{}) (string, error) {
q, _ := params["query"].(string)
if q == "" {
q = "相关内容"
}
if !a.rag.Loaded { // ★ 知识库空,直接返回 error
return "", fmt.Errorf("知识库为空,请先在「私人黑洞」上传文档")
}
answer, _ := a.rag.Query(q)
return answer, nil
},
get_weather 没有这种"前置不可用"状态,但 RAG 有——空库时调 RAG 没意义,浪费 LLM 调用。这里返回 error 让 LLM 收到信号会自动改用其他工具或告诉用户
特殊点 3:丢弃第二个返回值
a.rag.Query 原本返回 (answer, []SearchResult),工具闭包只取 answer,丢弃 results。
为什么?因为 SearchResult 里有 chunk_id、相似度分、原文片段等"调试信息",不该塞进 LLM 上下文——会污染 prompt、浪费 token、还可能让 LLM 误以为这些 ID 是它能引用的东西。命中详情通过 event bus 单独推到前端 SSE,给用户看。
📦 **额外知识:双通道输出 —— SSE 给前端、回包给 **LLM
同一个工具返回的数据可能分两路:
- LLM** 通道**:精简、自然语言(这条进 prompt)
- 前端通道:详细、结构化(用户能看到引用、来源) 把这两路分开是 Agent 系统的成熟做法。否则要么 LLM 被噪声淹没,要么前端没法展示来源。
特殊点 4:RaceGroup 设计
关键词规则降级时给 rag_search 加:
if _, ok := ts["rag_search"]; ok {
nodes = append(nodes, &graph.Node{
ID: graph.NodeID(nextID()), Type: graph.NodeTypeTool,
ToolName: "rag_search", Params: map[string]string{"query": query},
Name: "检索个人知识库",
DependsOn: []graph.NodeID{},
RaceGroup: "search", // ★ 跟 search_web 同组
})
}
search_web 也是 RaceGroup: "search"。两个工具竞速:
用户问 "K8s service 几种类型"
│
▼
┌──────┴──────┐
search_web rag_search ← 并发跑
│ │
└──┬──────────┘
▼
谁先非错就用谁,另一个被 cancel
image.png
特殊点 5:自反性(RAG 既能查、又能写)
rag_search 是只读的,但 tool_documents.go 的 write_document 让 LLM 还能往 RAG 里写:
{Name: "ingest_to_rag", Type: "boolean",
Description: "是否写入后立即进入 RAG 索引", Required: false},
LLM 写一份调研报告时可以决定"要不要让这份报告被检索"。这就形成了一个自反闭环:
- 用户问 A → rag_search 查不到 → search_web 查到 → LLM 整理成报告 → write_document(ingest_to_rag=true) → 下次问类似问题 → rag_search 命中 这是 "Self-improving RAG" 的雏形——Agent 在用户视角下只是个聊天框,背后它对自己的知识库做 CRUD
💡 一句话记住:RAG 工具相比普通工具,多了 5 个特殊点:闭包绑 Engine 实例、Loaded 状态前置检查、丢弃 results 只回 answer、跟 search_web 竞速、能让 Agent 往里写形成自反闭环。
9. 整合:一个真实例子从头跑
用户问 "帮我调研一下 K8s service 的几种类型,写成报告保存":
[1] HTTP Handler 收 query
│
▼
[2] runOnce → prepare
├─ STM 写入 "user: ..."
├─ 偏好提取(异步)
└─ routeDecide
↓
q 含 "调研" + "报告" + "保存" → needReAct=true → mode="react"
│
▼
[3] dispatch → runReAct
│
▼
[4] llmPlanGraph 调 Planner LLM
拼 prompt:[rag_search, search_web, write_document, research_agent, writer_agent, doc_agent...]
LLM 输出 JSON:
[
{"id":"n1","type":"sub_agent","agent":"research_agent","goal":"调研 K8s service","depends_on":[]},
{"id":"n2","type":"sub_agent","agent":"writer_agent","goal":"生成 Markdown 报告","depends_on":["n1"]},
{"id":"n3","type":"sub_agent","agent":"doc_agent","goal":"保存到本地+入 RAG","depends_on":["n2"]}
]
│
▼
[5] TaskGraph 拓扑分层:
Level 0: [n1]
Level 1: [n2]
Level 2: [n3]
│
▼
[6] GraphRuntime.Execute 逐层并行
Level 0:
n1.research_agent.Run()
└─ 内部又开一个小 ReAct 调用 rag_search + search_web 竞速
└─ 拿到一堆资料 → 返回 observation
Level 1:
n2.writer_agent.Run(upstream=n1的结果)
└─ LLM 把资料整合成 Markdown
Level 2:
n3.doc_agent.Run(upstream=n2)
└─ 调 write_document(ingest_to_rag=true) → PG 存原文 → RAG.Ingest 切分 + 向量化 + 入索引
│
▼
[7] llmGenerate 调 Generator LLM
合并 n1/n2/n3 的 observation → 自然语言答复
"我帮您调研了 K8s service 的 4 种类型:ClusterIP / NodePort / LoadBalancer / ExternalName...
报告已保存到本地文档库,文档 ID 为 xxx,已纳入 RAG 索引。"
│
▼
[8] finalize
├─ STM 写 "assistant: ..."
├─ 异步记忆抽取
├─ 异步记忆合并
└─ 发布 agent.chat 事件
image.png
整段流程触发的 LLM 调用次数:
- 1 次 Planner(路由 / 出 plan)
- N 次子 Agent 内部(research / writer 各自有自己的 LLM)
- 1 次 Generator(合成最终答案)
- 若干次偏好抽取 / 记忆抽取(异步,不阻塞)
10. 一句话总览
工具调用 = "给 LLM 装机械臂"。Saber 项目用"自实现 JSON 风格 function calling"——把工具描述拼进 prompt,让 LLM 吐 JSON,自己解析成 DAG,再用 GraphRuntime 做拓扑分层 + RaceGroup 竞速并行执行。四种路由(chat / tool / rag / react)按 query 关键词分发,最复杂的 ReAct 模式走 Planner LLM → DAG → 并行执行 → Generator LLM 五步。RAG 作为 tool 有 5 个特殊点:闭包绑 Engine、Loaded 状态前置检查、丢 results 只回 answer、跟 search_web 竞速、能形成"Agent 给自己写知识库"的自反闭环
11. 面试 5 连问(看完应该能答)
- 项目有 function calling 吗?怎么实现的?
有。但不是 OpenAI 原生的
tools字段,而是自己实现的 JSON 风格——把工具描述拼进 prompt 文本,让 LLM 吐 JSON,再自己json.Unmarshal解析成planNode。这样做的好处是任何能输出 JSON 的 LLM 都能跑,且能塞depends_on/race_group这种原生协议没有的字段 - LLM 输出不稳定怎么办? 6 道防线:prompt 工程 → 输出清洗 → 三档 schema 解析 → 工具白名单过滤 → 全失败降级到关键词规则 → 图层 DAG 校验失败降级全并行
- **多个工具怎么并行?有依赖怎么办?
**Planner 输出
depends_on字段定义依赖关系,构成 DAG。GraphRuntime 做拓扑排序按层调度,同层节点并行(受MaxParallel信号量限制) - 多个功能相似的工具怎么处理?
同
race_group的节点并发竞速,First-success-wins。典型例子是rag_search和search_web都属于"search"组,谁先非错就用谁,另一个被 cancel——本地优先,外网兜底 - RAG 作为 tool 和普通工具有什么不一样?
1.闭包捕获 Engine 实例(必须在 application 层注册)
2.Loaded 状态前置检查(避免空库浪费 LLM 调用)
3.只回 answer 丢弃 results(不污染 prompt)
4.跟 search_web 同 race_group 竞速、配套
write_document形成 self-improving 自反闭环