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。