九、Tool-Augmented Agent 工具调用设计:10个工具与ReAct循环的工程实践

当 LLM 不再只是"说话模型",而是能够调用搜索、浏览、对比、润色等实际工具的自主 Agent,智能客服系统的能力边界就从"回答问题"扩展到了"解决问题"。本文深入解析一个某个 FAQ 系统中的 Tool-Augmented Agent 设计------从 10 个工具的精细定义到 ReAct 循环的并发安全实现,展示如何构建生产级的工具调用引擎。

一、为什么需要 Tool-Augmented Agent?

传统 RAG(检索增强生成)系统的回答流程是固定的:收到问题 → 检索知识库 → 拼接上下文 → 生成答案。这种流水线在面对某个产品操作问答时,存在几个硬伤:

  • 检索目标不明确:用户问"系统X和系统B在审批流程上有什么不同?",系统不知道该用哪个文档范围,或者两个都搜然后对比。
  • 无法自主探索:用户说"告诉我系统X都有哪些模块",系统需要先浏览索引结构,再决定读哪个章节,而非一次性盲目检索。
  • 一步到位的幻觉风险:如果一次检索的结果不够好(得分低),系统没有"换种方式再搜"的自救机制。
  • 无法输出专业化结果:原始文档的语言偏技术化,直接返回给终端用户显得生硬,需要专业润色。

Tool-Augmented Agent(工具增强型 Agent) 的解决思路是:把每一项原子能力封装为一个独立的工具,让 LLM 充当"调度员",根据问题类型自主选择工具组合,在 ReAct(Reasoning + Acting)循环中逐步逼近最优答案。

复制代码
用户问题
    │
    ▼
┌─────────────────────────────────────────┐
│ LLM 推理(Think)                        │
│  ├─ 判断当前状态                        │
│  ├─ 选择下一步工具                      │
│  └─ 生成 tool_call 参数                 │
├─────────────────────────────────────────┤
│ 工具执行(Act)                          │
│  ├─ 匹配 _TOOL_EXECUTORS               │
│  ├─ 执行获取结果                        │
│  └─ 返回观察(Observation)             │
├─────────────────────────────────────────┤
│ 重复以上循环 → 直到 LLM 自然回答        │
└─────────────────────────────────────────┘
    │
    ▼
   最终回答

二、10 个工具的设计体系

系统定义了 10 个工具,全部采用 OpenAI Function Calling 格式 ,且设置 strict=true 启用结构化输出。这 10 个工具按职责分为四大类:

2.1 A 类:知识检索(核心能力)

这是系统的"拳头工具组",覆盖了从搜索到定位到原文读取的完整检索链路。

① search_knowledge ------ 知识库语义搜索
json 复制代码
{
  "name": "search_knowledge",
  "strict": true,
  "description": "搜索产品操作知识库,返回最相关的文档片段。适用于操作步骤查询、功能说明查询等明确的业务问题",
  "parameters": {
    "type": "object",
    "properties": {
      "query": {
        "type": "string",
        "description": "搜索查询语句,应提取问题的核心关键词"
      },
      "system": {
        "type": "string",
        "enum": ["系统X", "系统B", "系统C", "auto"],
        "description": "指定查询哪个业务系统,auto 表示自动识别"
      }
    },
    "required": ["query", "system"]
  }
}

这是最核心的检索工具,直接调用系统的多层检索引擎(navigate/index/direct 三种模式,6 种匹配算法)。LLM 除了提供搜索关键词外,还需要指定目标业务系统,这利用了系统的 target_systems 归属判断能力,在工具层面缩小检索范围,提升命中率。

system 参数的 auto 选项允许 LLM 在不确定归属时让系统自动识别,相当于给模型留了一个"不知道就交给系统判断"的兜底通道。

② browse_index ------ 浏览文档索引结构
json 复制代码
{
  "name": "browse_index",
  "strict": true,
  "description": "浏览知识库的文档索引结构,回答'有哪些模块''包含什么功能'等结构类问题",
  "parameters": {
    "type": "object",
    "properties": {
      "system": {
        "type": "string",
        "enum": ["系统X", "系统B", "系统C", "all"],
        "description": "指定查询哪个业务系统的索引"
      }
    },
    "required": ["system"]
  }
}

设计初衷 :当用户问"系统X有哪些功能模块?"这类概括性问题时,直接执行 search_knowledge 可能会返回碎片化的结果。browse_index 直接读取文档的索引导航结构,返回类似目录树的信息,让用户(和 LLM 自己)先建立对文档结构的全局认知。

这个工具与系统的索引文件格式紧密配合------每个索引文件都包含了章-节-条的层级结构、摘要、关键词和可能的提问示例。

③ read_section ------ 读取指定章节原文
json 复制代码
{
  "name": "read_section",
  "strict": true,
  "description": "读取知识库中指定章节的原文内容。当 search_knowledge 返回的片段不够详细时,可以精确读取该章节的完整原文",
  "parameters": {
    "type": "object",
    "properties": {
      "system": {
        "type": "string",
        "enum": ["系统X", "系统B", "系统C"],
        "description": "所属业务系统"
      },
      "chapter": {
        "type": "string",
        "description": "章编号,如'第1章'"
      },
      "section": {
        "type": "string",
        "description": "节编号,如'第3节'"
      },
      "article": {
        "type": "string",
        "description": "条编号,如'第5条'"
      }
    },
    "required": ["system", "chapter", "section"]
  }
}

定位策略search_knowledge 返回的是片段(可能是截断的或经过粗筛的),当 LLM 觉得需要更完整的上下文时,read_section 提供"拉取原始全文"的能力。参数设计采用 章/节/条 三级定位,与知识库文档的层级结构一一对应,确保定位精确到段落级别。

④ search_memory ------ 搜索历史高分回答
json 复制代码
{
  "name": "search_memory",
  "strict": true,
  "description": "在历史高分问答记录中搜索,适用于高频重复问题。评分越高、匹配度越高的记录优先返回",
  "parameters": {
    "type": "object",
    "properties": {
      "query": {
        "type": "string",
        "description": "搜索查询语句"
      }
    },
    "required": ["query"]
  }
}

这是 L2 记忆层的工具化封装。当用户问的问题在历史中已经被解答过且获得了高分(例如"如何创建工单"这样的高频问题),直接从记忆库取答案比重新检索知识库快得多------既节省 Token,又利用了过去已验证的优质答案。

搜索排序逻辑:匹配分数 × TTL 衰减 × 客户评分。系统支持三种 TTL 策略(hard/linear/exponential),让记忆既有"保鲜期"又不会突然消失。

json 复制代码
{
  "name": "web_search",
  "strict": true,
  "description": "当知识库中找不到答案时,使用互联网搜索兜底,适用于时效性强的政策法规、市场行情等问题",
  "parameters": {
    "type": "object",
    "properties": {
      "query": {
        "type": "string",
        "description": "搜索查询语句"
      },
      "count": {
        "type": "number",
        "description": "返回结果数量,默认为 3"
      }
    },
    "required": ["query"]
  }
}

兜底设计原则web_search 位于工具列表的最后优先级。LLM 的 TOOL_USE_SYSTEM_PROMPT 明确指导------只有在知识库搜索返回空结果或质量评分极低时,才启用互联网搜索。结果返回后需标注来源 URL,确保可追溯。

2.2 B 类:问题处理(推理增强)

⑥ decompose_question ------ 复杂问题拆解
json 复制代码
{
  "name": "decompose_question",
  "strict": true,
  "description": "当用户问题包含多个独立子问题时,拆解为多个子问题分别检索后再合成答案",
  "parameters": {
    "type": "object",
    "properties": {
      "question": {
        "type": "string",
        "description": "需要拆解的原始问题"
      },
      "sub_questions": {
        "type": "array",
        "items": {"type": "string"},
        "description": "拆解后的子问题列表"
      }
    },
    "required": ["question", "sub_questions"]
  }
}

设计要点 :这个工具的输出不是直接返回给用户,而是作为 ReAct 循环中的中间步骤。LLM 调用此工具获得子问题列表后,继续对每个子问题调用 search_knowledgebrowse_index 获取答案,最后汇总。这实际上实现了策略路由中策略 B 的"先规划再执行"思想,但是在工具层面而非策略层面。

⑦ compare_sections ------ 章节内容对比
json 复制代码
{
  "name": "compare_sections",
  "strict": true,
  "description": "读取两个不同章节的原文内容并对比,适用于'系统X和系统B的区别'等对比类问题",
  "parameters": {
    "type": "object",
    "properties": {
      "sections": {
        "type": "array",
        "items": {
          "type": "object",
          "properties": {
            "system": {"type": "string"},
            "chapter": {"type": "string"},
            "section": {"type": "string"},
            "article": {"type": "string"}
          },
          "required": ["system", "chapter", "section"]
        },
        "description": "需要对比的章节列表,支持2-3个章节"
      },
      "compare_dimension": {
        "type": "string",
        "description": "对比维度,如'功能差异''流程差异'"
      }
    },
    "required": ["sections", "compare_dimension"]
  }
}

这是一个复合工具 ------内部先调用 read_section 读取每个章节的原文,再用 LLM 进行对比分析。之所以设计为独立工具而非让 LLM 自己组合 read_section × N,是因为对比分析需要专门的"求同存异"视角,与普通阅读不同。封装为工具后,LLM 只需提供章节定位和对比维度,系统负责读取和对比的编排。

2.3 C 类:输出处理

⑧ polish_answer ------ 客服风格润色
json 复制代码
{
  "name": "polish_answer",
  "strict": true,
  "description": "将技术化的原始答案润色为专业、友好的客服风格,添加适当的引导语和结束语",
  "parameters": {
    "type": "object",
    "properties": {
      "answer": {
        "type": "string",
        "description": "需要润色的原始答案文本"
      },
      "source": {
        "type": "string",
        "description": "答案来源,如'系统X产品操作知识库-第2章第3节'"
      }
    },
    "required": ["answer"]
  }
}

角色转换 :知识库文档的语言偏技术操作手册风格------"点击XX按钮→选择XX选项→确认保存"。而客服风格需要更友好------"请您点击页面右上角的 XX 按钮,然后选择 XX 选项,最后点击确认即可完成保存"。polish_answer 工具让 LLM 在最终输出前进行一次风格转换,同时自动标注来源章节,增加可信度。

2.4 D 类:系统工具

⑨ list_documents ------ 文档全貌概览
json 复制代码
{
  "name": "list_documents",
  "strict": true,
  "description": "列出知识库中所有文档及其章节结构,适用于'你们有哪些文档''整个系统包含什么'等全景问题",
  "parameters": {
    "type": "object",
    "properties": {}
  }
}

browse_index 按系统浏览不同,list_documents 返回所有业务系统的完整文档清单和章节结构。无参数设计意味着它是一次"全量快照",适合 LLM 在不确定用户问题归属时先建立全局视图。

⑩ transfer_to_human ------ 转人工客服
json 复制代码
{
  "name": "transfer_to_human",
  "strict": true,
  "description": "当无法回答或用户明确要求转人工时,记录转人工事件并移交",
  "parameters": {
    "type": "object",
    "properties": {
      "reason": {
        "type": "string",
        "description": "转人工的原因说明"
      },
      "question": {
        "type": "string",
        "description": "原始用户问题"
      }
    },
    "required": ["reason", "question"]
  }
}

最后一道防线:当 LLM 经过多轮 ReAct 循环后仍无法给出满意答案,或者用户的情绪明显需要人工介入时,调用此工具。系统会将转人工事件记录到 CSV 文件(包含时间戳、用户问题、转人工原因),方便客服团队后续跟进和分析。

10 个工具全景总览

分类 工具 输入 输出 适用场景
A. 知识检索 search_knowledge query, system 文档片段 操作步骤/功能说明
browse_index system 索引结构 "有哪些模块"结构类
read_section system, chapter, section 完整原文 需要更详细上下文
search_memory query 历史高分问答 高频重复问题
web_search query, count 搜索结果 知识库未命中兜底
B. 问题处理 decompose_question question 子问题列表 复杂多意图问题
compare_sections sections\[\], dimension 对比分析 "A与B的区别"
C. 输出处理 polish_answer answer, source 润色后答案 技术→客服风格
D. 系统工具 list_documents 全文档清单 全景概览
transfer_to_human reason, question 转人工记录 无法回答/用户要求

三、ReAct 循环的实现

Tool-Augmented Agent 的核心执行引擎是一个 ReAct 循环 ,实现在 tool_augmented_ask() 函数中。

3.1 主循环流程

python 复制代码
# 伪代码: ReAct 循环主逻辑

def tool_augmented_ask(question, context):
    """Tool-Augmented Agent 主入口"""
    # 构建初始消息序列(含 system prompt 和历史)
    messages = build_initial_messages(question, context)
    
    for iteration in range(MAX_ITERATIONS):
        # Step 1: 调用 LLM(传入工具定义,让 LLM 自主选择)
        response = call_llm_with_tools(messages, TOOL_DEFINITIONS)
        
        # Step 2: 检查 LLM 是否选择调用工具
        if not response.has_tool_calls():
            # LLM 认为信息足够,直接给出最终回答
            return response.content
        
        # Step 3: 执行 LLM 选择的工具
        for tool_call in response.tool_calls:
            # 解析工具名称和参数
            tool_name = tool_call.get("name")
            tool_args = parse_arguments(tool_call.get("arguments"))
            
            # 从注册表中查找并执行工具
            executor = find_executor(tool_name)
            result = executor(**tool_args) if executor else "未知工具"
            
            # 将工具执行结果追加回消息序列(observation)
            messages.append(create_tool_message(tool_call.id, result))
        
        # 进入下一轮循环,LLM 看到 tool 结果后决定下一步
    
    # 超过最大迭代次数,转人工
    return "抱歉,我暂时无法回答这个问题,已为您转接人工客服。"
复制代码
**关键设计决策**:

1. **循环终止条件**:唯一终止信号是 LLM 选择**不生成 tool_calls**,直接以自然语言回答。这意味着 LLM 自主判断"我已经收集到足够信息了"。
2. **最大迭代保护**:`max_iterations=10` 防止无限循环,达到上限自动转人工。
3. **消息序列增长**:每次工具调用的结果以 `role="tool"` 追加到消息列表,LLM 的每一轮推理都能看到之前的工具结果(类似思维链的"观察"环节)。

### 3.2 流式工具调用处理

对于支持流式 thinking 模式的 LLM,系统实现了 `_collect_tool_stream()` 方法:

```python
# 伪代码: 流式 tool_calls 的增量收集
# 背景:流式 API 将 tool_calls 分多个 chunk 发送
# 每个 chunk 只携带增量数据,需要拼接完整

def collect_tool_calls_from_stream(response_stream):
    """
    对流式响应中的 tool_calls 进行增量收集。
    返回:(final_text, complete_tool_calls)
    """
    collected = {}  # index -> tool_call 的累积拼接
    final_text = []
    
    for chunk in response_stream:
        if chunk.has_tool_deltas():
            # 逐增量拼接:id、函数名、参数 JSON 片段
            merge_tool_delta(collected, chunk.tool_deltas)
        
        if chunk.has_content():
            final_text.append(chunk.content)
    
    return join_text(final_text), finalize_tool_calls(collected)

为什么需要这个 :OpenAI 兼容的流式 API 会将 tool_calls 拆分为多个 delta 块发送------比如工具名称在一个 chunk,参数在下一个 chunk,长参数甚至被切分到几十个 chunk 中。_collect_tool_streamindex 字段将碎片重组为完整的 tool_call。这是一个容易踩坑的实现细节,但也是流式工具调用必不可少的环节。

3.3 非流式 vs 流式路径

系统同时支持两种调用路径:

复制代码
tool_augmented_ask()
    ├── stream=False (默认)
    │   └── llm.chat_completion() → 同步获取完整 tool_calls
    │
    └── stream=True (thinking 模式)
        └── llm.chat_completion_stream() → _collect_tool_stream() → 组装完整 tool_calls

非流式路径简单直接,适合后台批量处理。流式路径虽然实现复杂,但能让用户在 LLM "思考"过程中看到实时输出(thinking token),提升交互体验。

四、工具执行器映射

系统通过 _TOOL_EXECUTORS 字典将工具名称映射到具体的执行函数,这是典型的策略模式

python 复制代码
# 伪代码: 工具名到执行函数的映射表
TOOL_EXECUTOR_REGISTRY = {
    "search_knowledge": execute_knowledge_search,    # 搜索产品知识库
    "browse_index":     execute_browse_index,        # 浏览文档索引
    "read_section":     execute_read_section,         # 读取指定章节
    "search_memory":    execute_memory_search,        # 搜索历史问答
    "web_search":       execute_web_search,           # 互联网搜索
    "decompose_question": execute_decompose,          # 拆解复杂问题
    "compare_sections": execute_compare,              # 对比两个章节
    "polish_answer":    execute_polish,               # 润色客服回答
    "list_documents":   execute_list_docs,            # 列出所有文档
    "transfer_to_human": execute_transfer_to_human,   # 转人工客服
}

# 统一接口:每个执行器接收关键字参数,返回字符串结果
# 新增工具只需:定义 TOOL_DEFINITIONS 中的 schema + 添加映射函数

每个执行器函数都遵循统一的签名模式------接收从 tool_call 参数 JSON 中解析出的关键字参数,返回字符串形式的执行结果。这种统一接口让新增工具变得非常简单:定义 TOOL_DEFINITIONS 中的 schema,然后在 _TOOL_EXECUTORS 中添加映射函数即可。

执行器内部实现示例

_execute_search_knowledge 为例,它实际上是系统检索管线的"代理人":

python 复制代码
async def _execute_search_knowledge(query: str, system: str = "auto"):
    """执行知识库搜索"""
    # 确定搜索范围
    target_systems = _resolve_systems(system)
    
    # 调用检索核心
    results = await retriever.search(
        query=query,
        target_systems=target_systems,
        top_k=3,
        mode=current_config.RETRIEVE_MODE  # navigate/index/direct
    )
    
    if not results:
        return "知识库中未找到相关信息。"
    
    # 格式化结果
    return _format_search_results(results)

各执行器分工明确:

执行器 内部调用 返回值特点
_execute_search_knowledge retriever.search() 格式化片段,标注来源章节
_execute_browse_index 读取索引文件 层级化目录树
_execute_read_section 原文定位器 完整章节原文
_execute_search_memory memory.search() 匹配分数+评分+答案
_execute_web_search Tavily/Bocha API 标题+摘要+URL
_execute_decompose_question LLM 拆分 JSON 子问题列表
_execute_compare_sections read_section × N + LLM 对比 结构化对比表格
_execute_polish_answer LLM 润色 客服风格文本
_execute_list_documents 遍历索引目录 完整文档清单
_execute_transfer_to_human CSV 记录 + 返回消息 确认转人工消息

五、与策略路由的关系

系统的 TOOL_USE_STRATEGY 配置项决定了工具调用的上层策略:

复制代码
TOOL_USE_STRATEGY = react → tool_augmented_ask() 直接 ReAct 循环
TOOL_USE_STRATEGY = auto  → planner.py 策略路由 → 选择 A/B/C/D/skip

react 模式(默认)

直接使用 tools.pytool_augmented_ask() 函数,LLM 在 ReAct 循环中自主选择 10 个工具。这是最简单直接的路径------LLM 既是规划者又是执行者,所有决策在循环中动态做出。

优点:实现简单,LLM 可以灵活应对各种意外情况,不需要预先规划。

缺点:每次工具调用都需要一次 LLM 推理,Token 消耗较大;LLM 可能"过度思考",调用不必要的工具。

auto 模式(策略路由)

通过 planner.py 先让 LLM 分析问题类型,路由到四种预定义策略之一:

策略 工具使用方式 典型场景
A (ReAct) 直接使用 tools.py 的 ReAct 循环 单步简单查询
B (ReWOO) 先规划工具序列,再按序执行 多独立子问题
C (ReWOO+ReAct) 规划大方向,每步灵活调整 跨章节有依赖的流程
D (ReWOO+Reflexion) 规划→执行→自检补漏 对比分析需完整性

四种策略共享同一个_TOOL_EXECUTORS 映射**和工具定义,区别在于执行编排方式:

  • 策略 A 直接调用 tool_augmented_ask(),走 ReAct 循环。
  • 策略 B/C/D 自行编排工具调用序列(先规划再执行),但仍然使用 _TOOL_EXECUTORS 中的函数作为执行单元。

这体现了分层设计的思想:工具执行(如何做)与策略编排(何时做)是分离的,策略路由只决定"调用顺序",工具模块只负责"执行能力"。

质量门控的衔接

无论是 react 还是 auto 模式,最终的输出都会经过质量门控(QUALITY_GATE)

复制代码
tool_augmented_ask() 输出 → QUALITY_GATE 评分
    ├── score >= 8 → 直接返回给用户
    ├── 5 <= score < 8 → 补搜一轮 → 再评估
    └── score < 5 → 回退到 ReAct(带已有结果上下文)

质量门控与工具系统的衔接之处在于:补搜时仍使用同一个工具集。即使未通过质量检查,已经执行过的工具结果也不会浪费------它们作为上下文保留在消息序列中,补搜轮次的 LLM 可以看到之前的搜索结果,站在已有信息的基础上继续探索。

六、并发安全设计

在生产环境中,一个 Web 服务可能同时处理多个用户的问答请求。如果多个请求共享同一个工具执行上下文,就会出现数据污染 ------用户 A 的工具调用结果被用户 B 看到。为此,系统使用 contextvars.ContextVar 实现请求级隔离:

python 复制代码
# 伪代码: 使用 ContextVar 实现请求级隔离
from contextvars import ContextVar

# 每个请求独立的上下文
_current_context: contextvars.ContextVar = contextvars.ContextVar(
    'tool_context', default=None
)

async def tool_augmented_ask(question, context, ...):
    # 设置当前请求的上下文
    token = _current_context.set(context)
    try:
        # ReAct 循环中的每一步都能通过 _current_context.get()
        # 获取当前请求隔离的上下文数据
        async for result in _react_loop(question):
            yield result
    finally:
        # 请求结束,恢复之前的上下文
        _current_context.reset(token)

ContextVar 的三大作用

  1. 请求级隔离 :每个请求的 _current_context 包含独立的对话历史、会话 ID、配置快照。同时处理的 100 个请求不会互相干扰。

  2. 执行器参数传递 :工具执行器函数通过 _current_context.get() 获取当前请求的配置(如检索模式、LLM 提供商选择),无需在每层函数调用中显式传递 context 参数:

python 复制代码
async def _execute_search_knowledge(query: str, system: str = "auto"):
    ctx = _current_context.get()
    retriever_mode = ctx.get("retrieve_mode", "navigate")
    # 使用 ctx 中的配置执行检索
  1. 异步安全contextvars 是 Python 官方推荐的异步上下文隔离方案。与 threading.local 不同,ContextVarasync/await 切换时能正确保持隔离------即使 await 导致事件循环切换到另一个协程,每个协程的 ContextVar 值仍然是独立的。

为什么不用显式参数传递?

一种替代方案是将 context 作为参数层层传递到每个执行器函数。但在 10 个工具的场景下,这意味着每个执行器函数都需要增加 context 参数,且调用链中的所有中间函数都需要透传。ContextVar 将上下文从"显式传参"变为"隐式获取",减少了函数签名污染,降低了新增工具时的改动成本。

七、TOOL_USE_SYSTEM_PROMPT 的设计

为了让 LLM 正确选择工具,系统设计了精密的 TOOL_USE_SYSTEM_PROMPT。其核心指导原则包括:

工具选择优先级

复制代码
1. 优先使用 search_knowledge 搜索具体问题(覆盖面最广)
2. 结构类问题("有哪些模块")使用 browse_index
3. search_knowledge 结果不够详细时,用 read_section 读原文
4. 对比类问题先用 search_knowledge 分别搜索,再用 compare_sections
5. 复杂多意图问题先用 decompose_question 拆分
6. 高频重复问题优先查 search_memory(Token 成本最低)
7. 知识库无结果时,最后尝试 web_search
8. 最终输出前调用 polish_answer 润色
9. 以上均不可解决时,调用 transfer_to_human

规则约束

  • 禁止连续调用同一工具 :如果第一次 search_knowledge 没找到结果,第二次应该用 browse_indexread_section 换角度尝试,而非相同关键词再搜一次。
  • 一次最多调用 3 个工具:单轮 ReAct 循环中,LLM 可以调用多个并行的 tool_calls,但不超过 3 个,避免一次性发太多请求。
  • 不要过度分解 :只有确实包含多个独立子问题时才调用 decompose_question,不要为简单问题增加不必要的中转。
  • 确保最终输出包含来源:无论是直接回答还是润色后的答案,都应该标注知识来源章节。

工具选择的动态性

TOOL_USE_SYSTEM_PROMPT 不是静态的------系统会根据当前配置动态调整提示词内容。例如,当 WEB_SEARCH_ENABLED=false 时,web_search 的描述会被修改为"已禁用";当 MEMORY_ENABLED=false 时,search_memory 被移除出可用工具列表。

八、工程实践中的关键优化

8.1 并行工具调用

OpenAI Function Calling 支持单次响应中返回多个 tool_calls。系统利用这一特性:

python 复制代码
# LLM 在一次推理中决定同时调用两个工具
# 这在 compare_sections 场景下尤其有用
tool_1 = search_knowledge(query="系统X审批流程", system="系统X")
tool_2 = search_knowledge(query="系统B审批流程", system="系统B")

# 两个工具并行执行
results = await asyncio.gather(
    _TOOL_EXECUTORS["search_knowledge"](**tool_1.args),
    _TOOL_EXECUTORS["search_knowledge"](**tool_2.args),
)

效果 :LLM 可以在同一轮中发起多个并行的工具调用(如同时搜索两个业务系统),asyncio.gather 让它们并发执行,显著减少总延迟。

8.2 工具结果缓存

在同一个会话的同一轮 ReAct 循环中,如果 LLM 不小心重复调用了同一参数的工具,系统会检查缓存:

python 复制代码
_tool_cache: dict = {}

async def _execute_with_cache(tool_name, args):
    cache_key = f"{tool_name}:{json.dumps(args, sort_keys=True)}"
    if cache_key in _tool_cache:
        return _tool_cache[cache_key]  # 返回缓存结果
    
    result = await _TOOL_EXECUTORS[tool_name](**args)
    _tool_cache[cache_key] = result
    return result

这在 LLM 的"探索性行为"中很常见------有时候它不确信之前的搜索结果是否完整,会尝试用相似的参数再搜一次。缓存避免了不必要的重复检索。

8.3 工具超时保护

每个工具执行都有独立的超时限制(默认 15 秒),防止某个工具(尤其是 web_search 这种外部 API 调用)卡死整个 ReAct 循环:

python 复制代码
async def _execute_with_timeout(executor, **kwargs):
    try:
        return await asyncio.wait_for(
            executor(**kwargs), 
            timeout=TOOL_TIMEOUT_SECONDS
        )
    except asyncio.TimeoutError:
        return f"工具执行超时({TOOL_TIMEOUT_SECONDS}秒),请重试或换用其他工具。"

九、总结与经验

架构全景图

复制代码
                          ┌─────────────────┐
                          │   TOOL_USE_STRATEGY  │
                          │  = react / auto  │
                          └────────┬────────┘
                                   │
                    ┌──────────────┴──────────────┐
                    │              │              │
                    ▼              ▼              ▼
              ┌──────────┐  ┌──────────┐  ┌──────────┐
              │  ReAct   │  │  ReWOO   │  │Reflexion │
              │ 循环     │  │ 规划执行 │  │ 自检补漏 │
              └────┬─────┘  └────┬─────┘  └────┬─────┘
                   │             │             │
                   └─────────────┼─────────────┘
                                 │
                                 ▼
                    ┌─────────────────────┐
                    │  _TOOL_EXECUTORS     │
                    │  (10 个工具执行器)    │
                    ├─────────────────────┤
                    │ search_knowledge     │
                    │ browse_index         │
                    │ read_section         │
                    │ search_memory        │
                    │ web_search           │
                    │ decompose_question   │
                    │ compare_sections     │
                    │ polish_answer        │
                    │ list_documents       │
                    │ transfer_to_human    │
                    └─────────────────────┘
                                 │
                    ┌────────────┴────────────┐
                    │  ContextVar 并发隔离      │
                    │  请求 A │ 请求 B │ 请求 C │
                    └─────────────────────────┘

关键设计原则

  1. 工具即能力抽象:每个工具封装一项原子能力,LLM 通过自然语言推断调用哪个工具。工具的粒度设计遵循"够用但不冗余"原则------10 个工具覆盖了问答全流程。

  2. 统一执行接口_TOOL_EXECUTORS 映射 + 统一签名模式,让新增工具的成本降到最低。只需要定义 TOOL_DEFINITIONS 和实现执行函数,两步即可完成。

  3. 循环而非流水线 :ReAct 循环的核心优势在于反馈驱动------每一步的工具结果都是下一步决策的依据。这与固定流水线不同,LLM 可以根据"观察"到的结果动态调整策略。

  4. 并发安全从设计第一天考虑:ContextVar 的方案比事后加锁优雅得多,它让工具执行器函数彻底摆脱了对请求上下文参数的依赖,同时也完全避免了并发污染。

  5. 分层编排 vs 自主任意react 模式让 LLM 自由选择工具(灵活),auto 模式通过策略路由约束工具编排(可控)。两者共享相同的工具执行器,体现了"能力与策略分离"的架构思想。

适用场景

这套 Tool-Augmented Agent 设计虽然源于某个 FAQ,但其架构模式具有通用性:

  • 企业内部知识库 QA:产品手册、操作指南、FAQ 的智能问答
  • SaaS 产品内嵌帮助:用户在产品界面中直接提问,Agent 调用多个工具检索答案
  • 客服辅助系统:人工客服的 AI 搭档,自动搜索知识库、对比文档、润色回复
  • 技术文档问答:SDK 文档、API 文档的多工具交互式问答

如果你正在构建一个需要自主调用多个工具的 Agent 系统,本文的设计思路------工具即能力、循环即策略、ContextVar 保安全------应该能为你提供一些可落地的参考。


本系列文章索引:

  • 01: Agent 记忆分层设计实践
  • 02: 多策略路由的智能客服系统设计
  • 03: 五种检索引擎与混合匹配架构
  • 04: Agentic RAG 质量自检与自适应重试
  • 05: 多意图查询拆分与递归分解
  • 06: 配置驱动架构与双模式管线
  • 07: LLM 基础设施层与提供商抽象
  • 08: 文档索引构建:代码提取加 LLM 填充的混合架构
  • 09: Tool-Augmented Agent 工具调用设计(本文)