4975 字
约 16 分钟
1
Saber工具调用全流程

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_calls JSON。优点:稳定,模型专门训练过。缺点:协议绑死、表达能力受限。
  • 自实现 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.gorouteDecide 分两条路径:

路径 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.gorunReAct,分 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_webinternal/infrastructure/tool/builtin.goDefaultTools() 静态注册:

func DefaultTools() map[string]tool.Tool {
    list := []tool.Tool{GetTime(), GetWeather(), SearchWeb()}
    ...
}

RAG 工具不在这里。看 core_agent.goregisterBuiltinTools

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.gowrite_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_searchsearch_web 都属于 "search" 组,谁先非错就用谁,另一个被 cancel——本地优先,外网兜底
  • RAG 作为 tool 和普通工具有什么不一样? 1.闭包捕获 Engine 实例(必须在 application 层注册) 2.Loaded 状态前置检查(避免空库浪费 LLM 调用) 3.只回 answer 丢弃 results(不污染 prompt) 4.跟 search_web 同 race_group 竞速、配套 write_document 形成 self-improving 自反闭环
Saber工具调用全流程
http://www.clxhxhhr.top/posts/799/
作者
clxstart
发布于
2026-09-18
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。
文章目录