Agent 工具描述是怎么动态注入的

一、先从最笨的办法讲起

一开始我把工具定义写死在 system prompt 里。每次调 LLM 的时候,直接把全部工具的 JSON Schema 拼进 prompt 末尾。代码大概长这样:

# v1: 全部塞进去
ALL_TOOLS = [...]  # 12 个工具的完整 JSON Schema,大概 800 token

def build_system_prompt():
    base = "你是旅行规划助手..."
    tools_block = json.dumps(ALL_TOOLS, ensure_ascii=False)
    return f"{base}\n\n可用工具:\n{tools_block}"

工具少的时候还好。后来加到 12 个,每次对话都带着航班、高铁、酒店、天气、门票、餐厅、地图……大部分跟当前问题无关。用户在问"成都哪里好吃",我硬塞了 search_flights 的 7 个参数定义进去。

问题有两个:token 浪费函数调用准确率下降(工具太多,模型容易选错)。

二、核心思路:按意图筛选工具

整个方案的核心逻辑很简单:

用户输入 → 意图识别 → 筛选相关工具 → 只把相关工具的 Schema 注入 system prompt

具体怎么实现?分三步:定义工具注册表、写意图路由器、写 Prompt 组装器。

三、第一步:建一个工具注册表

每个工具有两套描述——"轻量摘要"给路由器用,"完整 Schema"给执行 Agent 用。

from dataclasses import dataclass, field
from typing import Callable

@dataclass
class ToolDef:
    name: str                    # "search_flights"
    summary: str                 # "航班搜索"  ← 给路由器看的
    keywords: list[str]          # ["飞", "机票", "航班", "出发", "到达"]
    agent: str                   # 归属哪个子 Agent: "transport" | "hotel" | "food" | "trip"
    schema: dict                 # 完整 JSON Schema,只给执行 Agent 看
    handler: Callable            # 实际执行函数

# 注册表示例
TOOL_REGISTRY: dict[str, ToolDef] = {
    "search_flights": ToolDef(
        name="search_flights",
        summary="搜索航班",
        keywords=["飞", "机票", "航班", "出发", "到达"],
        agent="transport",
        schema={
            "type": "function",
            "function": {
                "name": "search_flights",
                "description": "搜索航班",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "from": {"type": "string", "description": "出发城市"},
                        "to":   {"type": "string", "description": "到达城市"},
                        "date": {"type": "string", "description": "出发日期 YYYY-MM-DD"},
                    },
                    "required": ["from", "to", "date"]
                }
            }
        },
        handler=search_flights_impl,
    ),
    "search_hotels": ToolDef(
        name="search_hotels",
        summary="搜索酒店",
        keywords=["住", "酒店", "住宿", "宾馆", "民宿"],
        agent="hotel",
        schema={...},
        handler=search_hotels_impl,
    ),
    "search_restaurants": ToolDef(
        name="search_restaurants",
        summary="搜索餐厅",
        keywords=["吃", "餐厅", "美食", "火锅", "小吃", "好吃"],
        agent="food",
        schema={...},
        handler=search_restaurants_impl,
    ),
    "get_weather": ToolDef(
        name="get_weather",
        summary="查询天气",
        keywords=["天气", "温度", "下雨", "冷", "热", "带伞"],
        agent="trip",
        schema={...},
        handler=get_weather_impl,
    ),
    # ... 更多工具
}

关键设计:keywords 不是摆设,是意图匹配的第一道 filter。

四、第二步:意图路由器——凭关键词 + LLM 做工具筛选

路由器分两段,先快速匹配、再让 LLM 做最终决策。

def route_tools(user_input: str) -> tuple[list[str], list[str]]:
    """
    返回: (被选中的工具名列表, 被选中的 agent 名列表)
    """
    # 阶段 1: 关键词快速匹配 —— 不调 LLM,零延迟
    matched_tools = []
    for name, tool in TOOL_REGISTRY.items():
        for kw in tool.keywords:
            if kw in user_input:
                matched_tools.append(name)
                break  # 一个工具命中一次就够了

    # 阶段 2: 如果关键词没命中任何工具,兜底调 LLM
    if not matched_tools:
        matched_tools = llm_intent_match(user_input)

    # 阶段 3: 从工具推导出需要哪些 Agent
    agents = list(set(TOOL_REGISTRY[t].agent for t in matched_tools))

    return matched_tools, agents

llm_intent_match 的 prompt 非常简单,只传入工具摘要表,让 LLM 根据语义选:

def llm_intent_match(user_input: str) -> list[str]:
    # 构建轻量工具摘要表(每个工具一行,12 个工具总共 ~150 token)
    summary_table = "\n".join(
        f"- {t.name}: {t.summary}"
        for t in TOOL_REGISTRY.values()
    )
    prompt = f"""根据用户输入,选择需要用到哪些工具。只输出工具名,用逗号分隔。

可用工具:
{summary_table}

用户输入:{user_input}
需要的工具:"""

    response = call_llm(prompt, max_tokens=50)  # 极短输出
    return [name.strip() for name in response.split(",")]

这里的关键是 LLM 只做选择题,不生成任何别的东西。输出 token 控制在 50 以内,延迟和成本都很低。

五、第三步:按 Agent 组装带完整 Schema 的 system prompt

路由器选好工具之后,每个 Agent 只拿到属于自己那几个工具的完整 Schema:

def build_agent_prompt(agent_name: str, selected_tools: list[str]) -> str:
    """为特定 Agent 构建 system prompt,只包含它需要的工具"""
    base_prompt = AGENT_BASE_PROMPTS[agent_name]  # 每个 Agent 有自己的角色提示

    # 筛选:只取属于当前 Agent 且被选中的工具
    my_tools = [
        TOOL_REGISTRY[name]
        for name in selected_tools
        if TOOL_REGISTRY[name].agent == agent_name
    ]

    if not my_tools:
        return base_prompt

    # 拼接完整 JSON Schema
    schemas = [t.schema for t in my_tools]
    tools_block = json.dumps(schemas, ensure_ascii=False, indent=2)

    return f"""{base_prompt}

你有以下工具可以使用:
{tools_block}

调用工具时,输出标准的 function_call JSON。
如果需要的工具不在你的列表中,回复:NEED_TOOL:<工具名>"""

对比一下 token 消耗的变化:

改造前:每个 Agent 的 prompt = 基础提示(~1400) + 全部 12 个工具的 Schema(~800) = 2200 token
改造后:每个 Agent 的 prompt = 基础提示(~1400) + 2~4 个相关工具的 Schema(~200) = 1600 token

一次对话五六轮,每轮省 600 token,累积省 3000+ token。

六、第四步:跨 Agent 工具回退

有时候用户的追问超出了当前 Agent 的工具范围。比如在"酒店 Agent"里用户突然问天气,酒店 Agent 没有 get_weather 工具。

处理方式:不修改当前 system prompt,而是把请求抛回主管重新路由。

def handle_agent_response(agent_name: str, response_text: str):
    """检查 Agent 输出中是否有 NEED_TOOL 信号"""
    if response_text.startswith("NEED_TOOL:"):
        tool_name = response_text.split(":", 1)[1].strip()
        # 找出拥有这个工具的 Agent
        target_agent = TOOL_REGISTRY[tool_name].agent
        # 把后续对话路由给新 Agent,并把缺失的工具 Schema 注入进去
        return reroute_to_agent(target_agent, extra_tools=[tool_name])
    return response_text

完整的跨 Agent 流程:

用户: "对了那几天天气怎么样"
  ↓
酒店 Agent: "我没有天气工具" → 输出 "NEED_TOOL:get_weather"
  ↓
主管解析 NEED_TOOL 信号 → 重新路由到 trip Agent
  ↓
trip Agent 拿到 get_weather Schema → 正常调用 → 返回天气信息

七、第五步:JSON Schema 精简(很容易忽视的优化)

翻了一遍 12 个工具的 Schema,发现很多"废话 description"。举两个例子:

// 废话型:description 只是重复了一遍参数名
"city": { "type": "string", "description": "城市名称,用于指定要搜索的城市" }

// 啰嗦型:一句话能说清的非要说三句
"budget": { "type": "integer", "description": "用户的旅行预算金额,单位为人民币元,用于筛选价格合适的酒店和航班" }

精简原则:

def simplify_schema(schema: dict) -> dict:
    """精简 JSON Schema 的 description 字段"""
    RULES = [
        # 规则1: description 跟参数名意思一样的,可以删掉
        ("city", lambda d: d.get("description", "") in ["城市名称", "城市", "the city name"]),
        ("date", lambda d: d.get("description", "") in ["日期", "the date"]),
        # 规则2: 删除"用于指定"、"该参数表示"这类占位词
        # 规则3: 枚举值直接写进 enum 字段,description 里不用再列一遍
    ]
    # ... 递归遍历 schema,命中规则的删 description 或替换为简短版本
    return simplified

精简前后对比:

改前: 12 个工具, ~1200 行 JSON, ~800 token
改后: 12 个工具, ~600 行 JSON,  ~450 token

更重要的是 function call 准确率提升了——废话 description 会干扰模型判断参数含义,砍掉后参数错误的概率从 ~8% 降到 ~3%。

八、完整流程串一遍

假设用户输入:"从深圳飞成都,住 3 晚,预算 5000"

# 1. 路由器:关键词匹配
user_input = "从深圳飞成都,住3晚,预算5000"

matched, agents = route_tools(user_input)
# matched = ["search_flights", "search_hotels", "plan_itinerary"]
# agents = ["transport", "hotel", "trip"]

# 2. 并行构建每个 Agent 的 system prompt 并调用
for agent_name in agents:
    agent_prompt = build_agent_prompt(agent_name, matched)
    # transport agent: base_prompt + search_flights 的完整 Schema(~1600 token)
    # hotel agent:     base_prompt + search_hotels 的完整 Schema(~1600 token)
    # trip agent:      base_prompt + plan_itinerary 的完整 Schema(~1600 token)

# 3. 各 Agent 独立执行,互不干扰
#    - transport Agent 只能调 search_flights,看不到酒店和行程工具
#    - hotel Agent 只能调 search_hotels
#    - trip Agent 只能调 plan_itinerary

# 4. 主管汇总各 Agent 结果,组装最终回复

如果中途用户追问 "到了成都怎么从机场去酒店":

# 路由器发现没有命中关键词 → 调 LLM 做语义匹配
matched, agents = route_tools("到了成都怎么从机场去酒店")
# LLM 判断需要: ["get_transportation"] → agent = "trip"
# trip Agent 的工具列表里新加入了 get_transportation 的完整 Schema

九、一周的实测数据

日志里拉了 200 轮对话对比:

指标 改造前 改造后
平均每轮 tool schema token 800 250
function call 参数错误率 8.2% 3.1%
选错工具的概率 5.5% 1.8%
需要跨 Agent 回退的比例 - 4.7%

思路不复杂,就是"按需加载"四个字。代码量也不大,从注册表到路由器到 prompt 组装,拢共 200 行 Python。