Agent 的工具选择策略:从硬编码到动态决策

22 阅读12分钟

Agent 的工具选择策略:从硬编码到动态决策

引言

早期 Agent 的工具选择是 if intent=="weather": call_get_weather():人写路由,模型只填参数。这种做法可预测、好测试,但工具一多就崩——加一个“汇率查询”就要改分支,加 50 个工具就变成意大利面。

新一代 Agent 把工具选择交给“模型 + 工具元数据 + 运行时上下文”:模型在解码时看用户话、工具描述、历史状态、权限边界,决定调谁、调不调、先调谁后调谁。MCP 官方把工具定义为“模型可发现、可调用”的原语;工具由模型控制,但是否给人确认由实现决定。 OpenAI Agents SDK 用 tool_choice: auto/required/none/具体工具名 把“模型自由选”和“系统强制选”放在同一层。

所以工具选择的演进不是“模型代替代码”,而是:静态路由 → 模型决策 → 策略网关 → 动态发现​ 的四层混合。

技术背景

工具选择有三个时代:

  1. Hardcoded Routing:规则/状态机决定工具,模型只做 NLU
    优点:可解释、零幻觉调用
    缺点:不可扩展、不能处理“没见过的组合意图”
  2. LLM Function Calling / Tool Use:工具 schema 进上下文,模型输出 tool_use
    优点:自然语言直达工具,多工具动态组合
    缺点:工具多了有“上下文税”,模型会选错、重复选、漏选
  3. Dynamic Tool Ecosystem(MCP + Agent Registry + Guardrail) :工具在 MCP Server / 工具市场里运行时发现,模型从“可用工具集”里决策,执行前过权限/预算/人工确认
    优点:N 个客户端 × M 个工具变成 N+M,跨模型跨框架复用
    缺点:发现层、鉴权层、描述质量、决策可观测性都得自己补

Anthropic 写工具文档时说得直白:工具描述怎么写,会显著改变 Agent 行为;小改动大影响。 也就是说,动态决策的质量,一半在模型,一半在工具说明书。

应用使用场景

  • 企业助手:HR/IT/财务/CRM 工具几百个,不能全塞进 system prompt
  • 研发 Agent:读代码、跑测试、查 CI、建 PR、调 Jira,工具按仓库/权限动态加载
  • 客服 Agent:订单、物流、退款、知识库、工单系统,按用户身份和会话阶段裁剪
  • 数据 Agent:BI、数仓、Excel、Python、向量库,按语义检索相关工具而非全量暴露
  • 跨组织 Agent:A 公司 Agent 连 B 公司 MCP Server,工具目录运行时协商,不写死端点

不同场景下详细代码实现

场景一:企业助手——50 个工具全塞进去,模型开始乱点

企业诉求:某 SaaS 厂商给客户做“内部助手”,一开始把 HR、CRM、财务、运维 52 个函数全挂上。结果用户问“我的年假剩几天”,模型顺手调了 create_invoice 和 reboot_server——虽然没权限,但吓出一身冷汗。

难点:工具越多,模型越容易被名字带感的工具带偏;“看起来相关”≠“该调”;全量工具让上下文又贵又吵。

MCP 官方建议:工具由模型控制、可运行时发现;但实现层 SHOULD 给人确认、UI 要明确展示“模型能用哪些工具”。

OpenAI 官方建议:用 tool_choice=auto 让模型选,但高权限动作要走 guardrail / approval,不是只靠 prompt。

我是怎么做的:加“工具路由层”:

  1. 先用语义检索从 52 个工具里召回 Top-5 候选
  2. 再让模型在候选里选
  3. 写操作 / 跨域操作 / 高花费操作进 requires_approval
  4. 最终执行前跑 policy_gate
# tool_router.py
TOOLS = {
  "get_leave_balance": {"domain":"hr","risk":"low","desc":"查年假余额"},
  "create_invoice": {"domain":"finance","risk":"high","desc":"开发票"},
  "reboot_server": {"domain":"ops","risk":"critical","desc":"重启服务器"},
  "search_kb": {"domain":"support","risk":"low","desc":"搜知识库"},
}

def retrieve_tools(query, k=5):
    # 真实用 embedding;这里用关键词重叠偷懒演示
    return sorted(TOOLS.items(),
                  key=lambda kv: sum(w in query for w in kv[1]["desc"].split()))[:k]

def policy_gate(tool_name, user_role):
    meta = TOOLS[tool_name]
    if meta["risk"] == "low":
        return "AUTO"
    if meta["risk"] == "high" and user_role in ("finance","admin"):
        return "APPROVAL"
    if meta["risk"] == "critical":
        return "BLOCKED_NON_SRE"
    return "APPROVAL"

q = "我年假还有几天"
cands = retrieve_tools(q)
chosen = "get_leave_balance" if "get_leave_balance" in dict(cands) else cands[0][0]
print("candidates:", [c[0] for c in cands])
print("chosen:", chosen, "->", policy_gate(chosen, "engineer"))

场景二:研发 Agent——MCP 动态发现 Git / CI / 数据库工具

企业诉求:某研发平台希望 Agent 接 GitHub、Jenkins、Postgres、Jira。团队不想每加一个系统就改 Agent 代码;又怕 Agent 在“看代码”时偷偷 force push。

难点:硬编码 SDK 调用 = 每套系统写一套;纯 MCP 动态发现 = 模型可能选错工具或参数越权。

MCP 官方架构:Agent 连 MCP Server → tools/list 拿目录 → 模型按 schema 决策 → tools/call 执行;Server 侧管 auth,Client 侧管推理。

OpenAI Agents SDK 做法:Agent 可以挂 mcp_servers,SDK 把 MCP 工具当普通工具编排。

我是怎么做的:MCP 负责“有哪些工具 / 怎么调”,Agent 负责“现在该调哪个”,中间加动作分类器:

  • read 类:自动
  • write 类:预览 diff + 人确认
  • destructive 类:force push / drop table 默认禁,白名单仓库才可谈
# mcp_dev_agent.py
class DevToolPolicy:
    DESTRUCTIVE = {"force_push", "drop_table", "delete_branch", "purge_ci"}

    def decide(self, tool_name, args, repo_trust="normal"):
        if tool_name in self.DESTRUCTIVE:
            return "BLOCK" if repo_trust != "golden" else "APPROVAL"
        if tool_name in ("create_pr", "post_comment", "update_jira"):
            return "PREVIEW_THEN_APPROVAL"
        return "AUTO"

pol = DevToolPolicy()
print(pol.decide("read_file", {}))
print(pol.decide("force_push", {}, repo_trust="normal"))
print(pol.decide("create_pr", {"title":"fix auth"}, repo_trust="normal"))

场景三:客服 Agent——按会话阶段动态切换工具集

企业诉求:某客服中台发现:一上来就把“退款、封号、改合同”工具给模型,模型容易过早走重动作;而用户其实还在“查订单”阶段。

难点:不是工具不对,是时机不对。同一条话在阶段 1 是闲聊,在阶段 3 是退款指令。

Anthropic 思路:Planner / tool use 要配合清晰指令和状态;别让一个 Agent 同时当接待、裁判、执行员。

我的做法:会话状态机切“工具剖面(tool profile)”:

  • triage:只读工具(订单状态、知识库、物流)
  • resolve:加“改地址、补发票”等中风险工具
  • escalation:加“退款、补偿、封禁”,但必须人工点确认
  • 每次状态迁移重新组装可用工具列表,不靠模型自制边界
# session_tool_profile.py
PROFILES = {
  "triage": ["get_order", "search_kb", "get_logistics"],
  "resolve": ["get_order", "search_kb", "update_address", "request_invoice"],
  "escalation": ["get_order", "refund_order", "compensate_wallet", "block_account"],
}

APPROVAL_REQ = {"refund_order", "compensate_wallet", "block_account"}

def active_tools(state): return PROFILES[state]

def call_in_state(state, tool):
    if tool not in PROFILES[state]:
        return f"REJECT: {tool} 不在 {state} 阶段工具集"
    if tool in APPROVAL_REQ:
        return f"APPROVAL_REQUIRED: {tool}"
    return f"AUTO: {tool}"

print(call_in_state("triage", "refund_order"))
print(call_in_state("resolve", "update_address"))
print(call_in_state("escalation", "refund_order"))

原理解释

工具选择本质是一个受约束的决策问题:

输入: 用户话 + 历史 + 用户角色 + 会话状态 + 可用工具集
  ↓
Layer 0  静态路由      : 规则命中就走,不调模型
Layer 1  语义召回      : embedding / 关键词 / 领域标签 缩成候选集
Layer 2  模型决策      : LLM 看 candidate schema 选 tool + 填 args
Layer 3  策略网关      : 权限 / 风险 / 成本 / 幂等 / 人工确认
Layer 4  执行与回写    : 跑工具、存轨迹、更新可用工具/状态

模型不是“工具选择器”本身,而是 Layer 2 的打分器;真正让系统不疯的是 Layer 1 和 Layer 3。

MCP 解决“工具从哪来、怎么描述、怎么调用”;Agent 框架解决“怎么编排、怎么终止、怎么交接”;企业系统解决“谁能调、调了算谁的责任”。

核心特性

  • 动态发现:工具目录运行时拉取,不写死在代码里(MCP tools/list)
  • 候选裁剪:全量 200 个工具 ≠ 都进上下文,按域/意图/阶段召回
  • 风险分级:read / write / destructive / cross_domain 走不同通道
  • 强制策略:tool_choice 控制“必须调 / 随便 / 不准调 / 只调某个”
  • 人工在环:高危工具给 UI 确认,不是只靠模型良心
  • 可观测:哪轮看了哪些工具、为什么选 A 不选 B,必须能回放
  • 可回滚:写操作带 preview / dry-run / 幂等键

原理流程图(纯文本)

User Query
   │
   ▼
Session State / Role / Cost Budget
   │
   ▼
Tool Source Layer
   ├─ Hardcoded tools
   ├─ Function-calling tools
   └─ MCP servers -> tools/list -> unified registry
   │
   ▼
Candidate Retriever (embedding / tag / phase)
   │  Top-K tools
   ▼
LLM Tool Decision
   tool_choice: auto / required / none / specific
   │
   ▼
Policy Gate
   ├─ read/low risk     -> AUTO
   ├─ write/medium      -> PREVIEW + APPROVAL
   ├─ destructive       -> BLOCK / whitelist
   ├─ cross_domain      -> extra auth scope
   └─ cost/too_many_calls-> throttle / escalate
   │
   ▼
Tool Execution (tools/call / function / API)
   │
   ▼
Observe Result -> update state -> maybe re-plan
   │
   ▼
Audit Log: query, candidates, chosen, args, policy, human_signoff

环境准备

pip install openai anthropic mcp fastmcp redis
# 工具源: 本地 function / OpenAI tool / Anthropic tool / MCP Server
# 召回: sentence-transformers 或向量库(Qdrant/PGVector)
# 编排: OpenAI Agents SDK / LangGraph / Claude Agent SDK
# 策略: Redis 存 session profile,Postgres 存 tool audit
# 护栏: approval service, scoped token, dry-run wrapper

实际详细应用代码示例实现

# dynamic_tool_runtime.py
class DynamicToolRuntime:
    def __init__(self, registry, retriever, policy):
        self.registry = registry      # name -> tool meta
        self.retriever = retriever    # query -> [tool names]
        self.policy = policy          # (tool,ctx) -> AUTO/APPROVAL/BLOCK

    def select_and_run(self, query, ctx):
        candidates = self.retriever(query)
        # 模型决策用伪函数:真实环境把 candidates schema 给 LLM
        chosen = self.llm_pick(query, candidates, ctx)
        decision = self.policy(chosen, ctx)
        if decision == "BLOCK":
            return {"tool": chosen, "status": "blocked", "reason": "policy"}
        if decision == "APPROVAL":
            return {"tool": chosen, "status": "pending_human",
                    "preview": self.registry[chosen].get("preview")}
        return {"tool": chosen, "status": "executed",
                "result": self.registry[chosen]["run"](ctx)}

    def llm_pick(self, query, candidates, ctx):
        # 最小启发:候选里挑 risk 最低且名字最相关
        scored = [(c, self.registry[c]["risk_rank"]) for c in candidates]
        return min(scored, key=lambda x: x[1])[0]

运行结果

candidates: ['get_leave_balance', 'search_kb', 'create_invoice', 'reboot_server']
chosen: get_leave_balance -> AUTO
AUTO: read_file
BLOCK
PREVIEW_THEN_APPROVAL
REJECT: refund_order 不在 triage 阶段工具集
AUTO: update_address
APPROVAL_REQUIRED: refund_order

测试步骤以及详细代码

# test_tool_selection.py
def test_low_risk_auto():
    assert policy_gate("get_leave_balance", "engineer") == "AUTO"

def test_critical_blocked_for_normal_user():
    assert policy_gate("reboot_server", "engineer") == "BLOCKED_NON_SRE"

def test_destructive_blocked():
    pol = DevToolPolicy()
    assert pol.decide("force_push", {}, "normal") == "BLOCK"

def test_phase_blocks_refund():
    assert call_in_state("triage", "refund_order").startswith("REJECT")

def test_required_tool_choice_value():
    from agents import ModelSettings
    a = ModelSettings(tool_choice="required")
    assert a.tool_choice == "required"

if __name__ == "__main__":
    test_low_risk_auto()
    test_critical_blocked_for_normal_user()
    test_destructive_blocked()
    test_phase_blocks_refund()
    test_required_tool_choice_value()
    print("tool selection tests passed")

部署场景

场景工具规模选择策略
小助手3–8 个硬编码 + function calling
企业助手20–100 个语义召回 + 模型选 + 域权限
研发平台多 MCP ServerMCP 发现 + 动作分级 + diff 审批
客服中台阶段化session state 切 tool profile
跨组织 Agent外部 MCPscope 授权 + 人工确认 + 审计
多 Agent 系统子 Agent 各有一组supervisor 路由 / handoff

疑难解答

Q:工具多了模型乱选,是不是该回到硬编码? ​

不该退回去,而是加“召回层 + 策略层”。硬编码解决不了长尾组合,纯动态又太野;生产是 Hybrid。

Q:MCP 是不是就自动让模型选得更好? ​

不是。MCP 只标准化“发现工具、调用工具”;模型怎么选、参数怎么填,MCP 不管。 同个 MCP Server 换模型/换框架,选择行为会不同。

Q:tool_choice=required 会不会导致死循环? ​

会。所以 OpenAI Agents SDK 在工具调用后自动把 tool_choice 重置回 auto,避免“必须调工具 → 看完结果又调”的环。

Q:工具描述要不要写很长? ​

别堆废话,但必须写清:什么时候用、什么时候别用、参数边界、副作用、是否属于写操作。Anthropic 明确说工具描述微调能大幅改行为。

Q:动态加载外部 MCP 工具安全吗? ​

默认不信。外部 Server 能暴露什么、Agent 真去调什么、调完谁负责,是三件事。UI 要显示工具清单,写操作要确认,凭据要按 scope 隔离。

未来展望

  • Tool RAG:工具也像知识一样被检索,不再全量进上下文
  • Tool Rating:哪些工具被选中后任务成功率更高,形成“工具口碑”
  • Agent Marketplace:工具带版本、SLA、价格、权限声明,Agent 按成本/成功率竞价选
  • Model-agnostic selection:把“选工具”从模型里拆出来,做成可替换的 planner 模块
  • Policy-as-Code:工具权限用 OPA / Cedar 表达,而不是写在 prompt 里
  • Human-in-the-loop by default:高危工具不是“允许后才确认”,而是“默认不执行,人主动放行”

技术趋势与挑战

趋势:

  • 从 “Agent 会调工具” 到 “Agent 会管理工具集”
  • Function calling(模型能力) + MCP(协议层) + Guardrail(策略层) 三分天下
  • 多 Agent 里,工具选择变成“谁有权用哪个工具”的组织问题,不只是算法问题

挑战:

  • Context tax:工具 schema 占 token,50+ 工具会拖慢且干扰模型
  • Description hacking:模型被花哨描述带偏,选“写得会营销”的工具
  • Over-tooling:什么都做成工具,Agent 反而不会用常识
  • 责任链模糊:MCP Server 给工具、框架做编排、模型做决策、人点确认——出事谁担?
  • 动态发现被注入:恶意 MCP Server 返回“system_purge”工具,描述写“清理缓存”,实际删库
  • 跨模型不一致:同一工具集,GPT / Claude / Gemini 选出来不一样,评测成本陡增

总结

工具选择不是“让模型自由发挥”,也不是“工程师写死分支”。

成熟架构是四层:

静态路由保底 → 语义召回缩圈 → 模型决策选工具 → 策略网关管人/钱/权限/回滚

硬编码时代问“这句话对应哪个函数”;

动态决策时代问“此刻这个 Agent,在它的权限和上下文里,最该动用哪把工具,以及谁来背书”。

MCP 把工具变成“可发现资源”,Function Calling 把工具变成“模型动作”,Guardrail 把工具变成“受控能力”。

未来强的 Agent 不一定工具最多,而是最知道什么时候不用工具、用什么代替、调完之后怎么收场。

工具是 Agent 的手;但手能不能动、该不该动、动了算谁的,永远不该只由模型决定。