工具注册了却不被调用:从微软 AI Agent 入门课的插件测试文件反查
现象长什么样
你照着课程第四课写了三个工具,tools= 也传进去了,一问就发现模型压根没打算调用它们——它会很客气地回答「我可以帮你查询目的地」,然后给一段编出来的文字,日志里一条 tool call 都没有。程序不报错,这是最难受的地方:报错至少给你一个栈,工具不被选中什么都不给你。
ai-agents-for-beginners 仓库的 04-tool-use/README.md 把这个机制说得很直白:函数的代码要被调用,模型必须拿用户请求去和函数的描述做比对;做比对的依据是一份包含所有可用函数描述的 schema,模型从中挑一个,返回函数名和参数,框架再去执行它,把返回值送回模型。这是仓库文档自述的流程。顺着这句话往回推,「不被调用」这件事只可能断在两个地方:工具对象根本没进到那份 schema 里,或者进去了但描述写得让模型认不出来。
下面这条反查路径就按这两段来。仓库持续更新,文中的文件路径与接口写法随版本变动,请以仓库最新内容为准。
第一步:先确认工具实体真的存在
这一步听着多余,但课程仓自己就留了一个现成的反面样本。04-tool-use/code_samples/test_demo_plugins.py 的第一行是:
from demo_tool_agent import TimePlugin, CalculatorPlugin
而在仓库里搜 demo_tool_agent、TimePlugin、CalculatorPlugin 这三个名字,除了这个测试文件自己,别处一个都搜不到——04-tool-use/code_samples/ 目录下只有 04-python-agent-framework.ipynb、04-dotnet-agent-framework.cs、04-dotnet-agent-framework.md 和这个测试文件本身。也就是说,这份测试所依赖的插件模块,在当前仓库快照里并不存在。
这正是「工具不被调用」最朴素的一种成因:你以为注册进去的那个对象,导入路径上已经不是原来那个东西了。判定动作很简单,在项目目录里直接搜类名。
Linux / macOS:
grep -rn "CalculatorPlugin" .
Windows PowerShell:
Get-ChildItem -Recurse -File | Select-String -Pattern "CalculatorPlugin"
(这两条是通用的检索做法,不是课程仓提供的官方命令。)
再补一刀:直接让 Python 去解析导入。
python -c "from demo_tool_agent import TimePlugin, CalculatorPlugin; print(TimePlugin, CalculatorPlugin)"
如果这里抛 ModuleNotFoundError 或 ImportError,那和描述写得好不好毫无关系,先把模块补齐再说。这个判定放在最前面,是因为它最便宜,也最容易被跳过——大家总倾向于先怀疑提示词。
第二步:确认工具的名字与审批模式
工具对象在,下一步是看它被框架加工成了什么样。04-python-agent-framework.ipynb 里有一段现成的自查写法,是课程用来演示审批模式的,拿来当排查手段刚好:
print("Tool name:", book_flight.name)
print("Approval mode:", book_flight.approval_mode)
被 @tool 装饰过的函数会带上 name 和 approval_mode 两个属性,打印一下就知道框架到底把它认成了什么。这里有个特别容易误判的点:notebook 的「Tool Approval Patterns」一节写明,approval_mode 取 "never_require" 时工具自动执行、不需要用户确认,取 "always_require" 时每一次调用都必须先经用户批准才会执行;课程建议给有副作用的操作(比如订机票、扣款)用后者,把人留在回路里。
于是就会出现这种情况:模型其实已经选中了工具,只是卡在等待批准,你在外面看着像「没调用」。所以打印出来发现是 always_require,那这条排查线就到此为止了,问题不在描述上。notebook 里那个 book_flight 用的正是 @tool(approval_mode="always_require")。
第三步:看描述和参数注解到底写没写
工具在、名字对、审批模式也不拦着,那就轮到描述了。notebook 的「Defining Tools with the @tool Decorator」一节把三条来源写得很清楚:docstring 会成为模型看到的工具描述;类型注解(包括带描述文字的 Annotated)定义工具的 schema;approval_mode 管执行前要不要人批准。README 里也说,框架会自动把函数和它的参数序列化成发给模型的 schema。
课程里的写法长这样:
@tool(approval_mode="never_require")
def check_availability(
destination: Annotated[str, "The destination to check"],
) -> str:
"""Check booking availability for a destination."""
...
把这段和 README 里那份手写 JSON schema 对照着看,就明白 @tool 到底在替你填哪几个格子。README 用 Responses API 的扁平工具格式给出的原始形态是这样的:
tools = [
{
"type": "function",
"name": "get_current_time",
"description": "Get the current time in a given location",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city name, e.g. San Francisco",
},
},
"required": ["location"],
},
}
]
对应关系一目了然:函数名对 name,docstring 对顶层 description,参数的类型注解对 properties 里的类型,Annotated 的第二个元素对参数级的 description。你省掉的每一样,schema 里就空一格。 一个没有 docstring 的工具,在模型眼里就是一个只有名字的函数;一个参数只标了 str 没标 Annotated 描述的工具,模型知道要传字符串,但不知道该传城市名还是机场三字码——notebook 里 get_flight_info 的两个参数分别注成了出发地机场代码和目的地机场代码,就是为了消掉这层歧义。
处置动作因此只有三条,一条条补:给每个工具补一句说清楚「什么时候用它」的 docstring;给每个参数补 Annotated 描述;确认函数名本身是可读的动词短语,而不是 func1 这种。
第四步:处置完怎么验证
验证分两层,第一层不碰模型:
travel_tools = [get_destinations, check_availability, get_flight_info]
for t in travel_tools:
print(t.name, "|", t.approval_mode)
这段是按仓库代码中的接口语义组合的示例,未经实测,以仓库最新代码为准。它只确认一件事——你手里的列表和你以为的那个列表是同一个。
第二层才是让 agent 跑。课程里创建 agent 的写法是 client.as_agent(name=..., instructions=..., tools=travel_tools),其中 instructions 明确写了「使用可用工具回答关于目的地、可用性和航班的问题」。instructions 和工具描述是两道独立的闸门,只补一边不算补完。
如果你需要一个更硬的验证信号,notebook 的「Structured Output with Tools」一节给了办法:把 response_format 设成 Pydantic 模型,课程定义了 BookingRecommendation 与 TravelPlan 两个模型,前者带 destination、available、flight_details、estimated_cost 四个字段。要求模型返回强类型 JSON,比读一段自由文本更容易判断工具的返回值有没有真的流进结果里。
第五步:什么情况说明不是这个原因
这一步不能省,否则你会在描述上反复打磨一个根本不在这儿的问题。
其一,配置缺失不是描述问题。 notebook 的 setup 单元格会检查 AZURE_AI_PROJECT_ENDPOINT 与 AZURE_AI_MODEL_DEPLOYMENT_NAME 两个环境变量,缺哪个就把缺失项的名字拼进消息里 raise ValueError。这类失败是当场抛异常,不会表现成「模型不调用工具」。同理,FoundryChatClient 配 DefaultAzureCredential 的凭据问题也属于连不上,不属于选不中。
其二,那份测试文件绿了也证明不了工具会被调用。 test_demo_plugins.py 断言的是纯本地计算和字符串格式:calc.add(5, 7) 要等于 12、calc.subtract(10, 4) 要等于 6(这些是仓库示例代码里的取值),时间插件那条则是 assert len(time_str) == 19,外加断言字符串里含 - 和 :,注释写明期望的格式是 YYYY-MM-DD HH:MM:SS。整条链路里没有模型、没有 schema、没有 tool call。它验证的是「插件方法本身算得对」,不是「模型愿意选它」——把这份测试当成工具链路的回归门,会漏掉全部选择环节的问题。
顺带说一句这个文件的写法:它没有引入 pytest,而是在 __main__ 里依次调用三个函数,外面套一个 try,except AssertionError 打印 Test failed:,except Exception 打印错误信息。由于文件里所有 assert 语句都没有附带消息参数,断言失败时打印出来的那句 Test failed: 后面是空的;脚本本身也没有以非零状态退出的语句。这两点放在一起看意味着:它是一个给人看输出的演示脚本,不是一个能挡住 CI 的测试。
其三,如果你写的是 .NET 那一份,结论不能照搬。 04-tool-use/code_samples/04-dotnet-agent-framework.cs 里描述不来自 docstring,而是方法和参数上的 [Description(...)] 特性,工具通过 AIFunctionFactory.Create(...) 注册。排查思路一致,但你要去查的位置是特性有没有加、Create 有没有把方法真的挂进去,而不是找 docstring。这两份实现别混着看。
其四,如果模型明确回复「我没有可以做这件事的工具」,那更像是工具确实没进 schema(回到第一步和第二步),而不是描述不够清楚。 描述不清楚的典型表现是它选错了工具,或者参数填得离谱,而不是干脆说没有。
本文依据 github.com/microsoft/ai-agents-for-beginners 仓库于 2026-08-18 的公开内容整理,
事实来自仓库内的课程正文与代码示例。我们没有跑过文中涉及的代码,
因此不涉及运行结果、耗时与 Agent 实际表现的任何描述。
该课程持续更新,文中涉及的文件路径、依赖与接口写法随版本变动,请以仓库最新内容为准。
文中涉及的云端服务调用会产生费用并可能上传数据,请自行评估密钥与数据边界。