
上周出了一次挺丢人的事故。一个学生问助教小E:"我明天的课是几点?"小E秒回:"您的写作课在周三下午三点,3 号教室。"学生第二天下午三点到了 3 号教室------那天根本没排课,写作课是周六上午的。
另一个学生问"accord 这个词在你们词表里是几级",小E答得更有底气:"C1 级,考研核心词,建议精读。"实际上它在词表里是 B2,考研大纲里压根没有它。
学生白跑一趟还是小事。带队老师看了聊天记录问我:这模型是不是坏了?
模型没坏,它甚至表现得很"称职"------生成一句像模像样的回答本来就是它唯一会做的事。它没有我们的课表,没有我们的词表,你却要求它回答里面的问题,它只能拿训练时见过的模式补全一个"最像答案的文本"。前六篇我们一直在打磨它"怎么说",这一篇要解决的是更根本的问题:让它闭嘴去查。
一、先扫盲:工具调用到底是谁在干活

市面上太多文章把工具调用写成"模型自己去查了课表",这是最大的误导。真实流程是四步,一步都绕不开人写的代码:
- 你的程序把问题连同工具清单一起发给模型;
- 模型不执行任何东西,它只回一行结构化意图:"我想调
get_schedule,参数是student_id=S1001, date=2026-07-25"; - 你的程序解析这行意图,去真的执行,把结果包成一条 ToolMessage 追加进消息列表;
- 再问一轮模型,它看着结果组织人话。
所以准确的说法是:**模型是动嘴的,你的程序是动手的。**模型从头到尾没碰过你的数据库,它只是"点菜",炒菜、端菜全是你的代码。想通这一点,后面所有的工程决策都顺理成章------工具的可靠性、安全性、报错方式,全都取决于你的代码怎么写,跟模型关系不大。
工具 schema 的三要素
模型是"读着说明书挑工具"的。说明书就是每个工具的 schema,一共三样东西:
- name :工具名。起名要像动词:
lookup_word、get_schedule。别起tool_1。 - description:干什么用、什么时候该用。这是模型决定"调不调"的唯一依据。
- 参数说明:每个参数是什么、什么格式。这是模型决定"怎么传"的唯一依据。
我在 description 里会写两句"当学生问......时调用本工具"和"不要凭印象回答,一律以本工具返回为准"。听起来啰嗦,但模型真的在读这些话。同样,参数说明里我写死了"必须是 YYYY-MM-DD 格式,例如 2026-07-25"------不写这句,模型会把你最不想看到的字符串原样传进来。描述写得含糊,模型就不会用、或者乱用,这不是玄学,是说明书问题。
tool_calls → ToolMessage → 再问一轮
模型想调工具时,返回的消息里会带一个 tool_calls 字段,每个调用有三样:工具名、参数、还有一个 id。你的程序执行完,要构造一条 ToolMessage(content=结果, tool_call_id=那个id) 追加进消息列表,再 invoke 一次。id 是用来对账的------哪条结果回应哪个调用,接口靠它核对,对不上直接报错。
这个"问一次、执行、回传、再问"的循环就是 Agent 的心脏。本篇我们把它手写一遍,再用 LangGraph 的预置实现跑一遍。
二、工程目录
ruby
07-工具调用/
├── src/
│ ├── settings.py # 复用第 2 篇
│ ├── llm.py # 复用第 2 篇
│ ├── tools.py # 三个 @tool 本地工具 + 默认数据
│ ├── registry.py # TOOLS 清单 + 查找 + schema 快照
│ ├── loop_guard.py # 步数上限 + 重复调用检测
│ ├── agent.py # run_manual(手写循环)/ run_react(预置 ReAct 图)
│ └── fakes.py # 记录型脚本假模型
├── tests/ # 38 个离线用例
├── demo.py
├── pytest.ini / requirements.txt / README.md / .env.example / .gitignore
三个工具都不联网,数据来自构造时传入的字典。真实项目里换成查数据库或调教务系统接口,函数签名一个字不用改。
三、tools.py:工具是给程序用的,报错要说人话
先看最核心的"查课表"。三个工具我用工厂函数造而不是直接写在模块顶层,原因在注释里:
python
def make_get_schedule(
schedules: Mapping[str, Mapping[str, list[dict[str, str]]]],
) -> BaseTool:
"""造一个"查课表"工具,数据来自传入的字典。"""
@tool(parse_docstring=True)
def get_schedule(student_id: str, date: str) -> str:
"""查询某个学生在指定日期的课表。
当学生问「我明天/某天有课吗」「几点上课」「在哪间教室」「哪个老师」时调用本工具。
时间、教室、老师一律以本工具返回为准,不要凭印象推断。
Args:
student_id: 学号,大写 S 开头加四位数字,例如 S1001。
date: 要查的日期,必须是 YYYY-MM-DD 格式,例如 2026-07-25。
"""
sid = (student_id or "").strip()
day = (date or "").strip()
# 两个参数都可能是模型"自作主张"填出来的,逐个挡回去
if not sid:
return "没有拿到学号。请让学生提供学号(S 开头加四位数字,例如 S1001)。"
if not _DATE_RE.match(day):
return (
f"日期「{day or '(空)'}」格式不对,需要 YYYY-MM-DD,例如 2026-07-25。"
"如果学生说的是「明天」「下周三」,请先换算成具体日期再调用。"
)
try:
weekday = _weekday_of(day)
except ValueError:
return f"「{day}」不是一个真实存在的日期,请核对年月日后重新查询。"
own = schedules.get(sid)
if own is None:
return _not_found_student(sid)
lessons = own.get(day) or []
if not lessons:
return f"{sid} 在 {day}({weekday})没有排课,可以安排自习或预约补课。"
lines = [f"{sid} · {day}({weekday})共 {len(lessons)} 节课"]
for i, lesson in enumerate(lessons, start=1):
lines.append(
f"{i}. {lesson.get('time', '')} {lesson.get('course', '')}"
f" · {lesson.get('teacher', '')} 在 {lesson.get('room', '')}"
)
return "\n".join(lines)
return get_schedule
三个刻意的决定,一个比一个重要:
**第一,用工厂函数把数据"注入"进去。**测试里塞两三条假数据就能断言命中与未命中,上线时换成从数据库读的那份------工具的逻辑和数据是分离的,这才叫工程对象,不叫脚本。
**第二,docstring 用了 @tool(parse_docstring=True) 而不是裸 @tool。**这是我本篇踩得最深的一个坑,单列在踩坑清单第 1 条:langchain-core 1.x 里这个开关默认是关的,Args: 段不会被解析进参数 schema,模型拿到的就只有一个光秃秃的 {"word": {"type": "string"}}------参数说明整段丢失。后面 registry.py 里有测试专门盯这件事。
**第三,参数不合法时返回友好错误文本,绝不抛异常。**这是工具调用里最反直觉又最要命的工程细节:工具是在循环里被调用的,一个 ValueError 会顺着循环一路炸到最外层,学生看到的是"系统错误",而不是"日期格式不对"。更妙的是,一段说清楚错在哪、该怎么改的文本喂回给模型后,模型有机会当场自救------它会把"明天"换算成日期再调一次。抛异常则什么都救不了。报错文本本身就是给模型的提示词,这个观念转过来,工具就活了。
另外注意返回值全是字符串,还带着编号和换行------这段文本会被原样塞进 ToolMessage,既给模型看,也给人看(日志里它是最常被翻的行)。
lookup_word 和 get_learning_record 结构完全一样,不再贴全文,只看一眼查询未命中的话术:
python
def _not_found_word(word: str) -> str:
"""词表没命中时的统一话术。"""
return (
f"词表里暂时没有「{word}」这个单词。"
"如果你确认拼写没错,可以先按通用释义讲,或让教研同事把它补进分级词表。"
)
**"没查到"必须是一个明确的、可读的答案,而不是 None,更不是编一个。**本篇开头的"周三下午三点",本质就是没有工具兜底时模型替数据发了言。
四、registry.py:工具清单只留一个真相
python
TOOLS: list[BaseTool] = build_tools()
def get_tool(name: str, tools: Sequence[BaseTool] | None = None) -> BaseTool:
pool = list(tools) if tools is not None else TOOLS
for item in pool:
if item.name == name:
return item
raise KeyError(f"工具 {name} 不存在,当前可用:{[t.name for t in pool]}")
def schema_snapshot(tools: Sequence[BaseTool] | None = None) -> list[dict[str, Any]]:
"""导出工具 schema 快照:name / description / parameters 三件套。"""
pool = list(tools) if tools is not None else TOOLS
return [
{
"name": item.name,
"description": item.description or "",
"parameters": item.args_schema.model_json_schema()
if item.args_schema
else {},
}
for item in pool
]
get_tool 对不存在的名字直接抛 KeyError,不静默兜底------名字写错是开发期就该炸的 bug,拖到线上就是模型拿着错误结果推理三轮。schema_snapshot 是给测试用的:哪天有人把 docstring 里的 Args: 段删了,或者忘了 parse_docstring=True,参数说明就从 schema 里消失,模型随即开始乱填参数------这种 bug 在代码 review 里根本看不出来,用例一跑就红。
五、loop_guard.py:凡是循环,先回答"它一定会停吗"

工具调用把"一次问答"变成了"一个循环",循环必须配护栏。我遇到过一次真实的打转:学生说"下周三",模型不知道今天几号,连着三轮调 get_schedule(student_id="S1001", date="下周三"),每次拿到同一句"日期格式不对",然后原样重试------三轮下来烧了三倍 token,学生看到同一句错误连发三遍。
python
DEFAULT_MAX_STEPS = 4 # 三个工具,最多三轮查证 + 一轮组织语言
def call_key(name: str, args: Mapping[str, Any]) -> str:
"""把"工具名 + 参数"压成一个稳定的字符串,用作去重键。"""
return json.dumps({"name": name, "args": dict(args or {})},
sort_keys=True, ensure_ascii=False)
class LoopGuard:
def next_step(self) -> int:
"""开始新的一步:步数 +1,超限就抛。"""
if self.steps >= self.max_steps:
raise StepLimitExceeded(f"已达步数上限 {self.max_steps},强制收工")
self.steps += 1
return self.steps
def is_repeated(self, name: str, args: Mapping[str, Any]) -> bool:
return call_key(name, args) in self._seen
def repeat_notice(self, name: str, args: Mapping[str, Any]) -> str:
previous = self._seen.get(call_key(name, args), "(上次结果已丢失)")
return REPEAT_NOTICE.format(name=name, previous=previous)
两个细节。json.dumps(sort_keys=True) 是为了让"参数一样但键序不同"的两次调用判成重复------模型传参的键序真的不稳定。重复的处置方式是"回提示"而不是"抛异常":把上次结果再贴一遍,告诉它"要么换参数,要么直接回答",模型有机会自救;只有步数耗尽才由上层强制收工。护栏是来劝架的,不是来掀桌的。
六、agent.py 之一:run_manual,手写那三十行
python
def run_manual(
question: str,
llm: BaseChatModel,
tools: Sequence[BaseTool] | None = None,
*,
max_steps: int = DEFAULT_MAX_STEPS,
system: str = SYSTEM_PROMPT,
) -> AgentResult:
pool = list(tools) if tools is not None else TOOLS
by_name = {tool.name: tool for tool in pool}
# bind_tools:把工具 schema 挂在模型上,模型才知道自己有哪些工具可用
llm_with_tools = llm.bind_tools(pool)
messages: list[BaseMessage] = [
SystemMessage(content=system),
HumanMessage(content=question),
]
guard = LoopGuard(max_steps)
steps: list[StepRecord] = []
while True:
# 先过步数护栏:每一步都是真金白银的模型调用,不能无限
try:
index = guard.next_step()
except StepLimitExceeded:
return AgentResult(question, FALLBACK_ANSWER, steps, STOP_MAX_STEPS)
ai = llm_with_tools.invoke(messages)
messages.append(ai)
calls = list(getattr(ai, "tool_calls", None) or [])
if not calls:
# 模型不再要工具 → 它这次给的就是最终回答
return AgentResult(question, _text_of(ai), steps, STOP_FINAL)
record = StepRecord(index=index, tool_calls=[dict(item) for item in calls])
stopped: str | None = None
for position, item in enumerate(calls):
name = str(item.get("name") or "")
args = dict(item.get("args") or {})
if guard.is_repeated(name, args):
# 重复调用:不执行,回一段提示让模型知道它打转了
observation = guard.repeat_notice(name, args)
stopped = STOP_REPEAT
else:
observation = _run_tool(by_name, name, args)
guard.remember(name, args, observation)
record.observations.append(observation)
messages.append(
ToolMessage(
content=observation,
tool_call_id=str(item.get("id") or f"call_{index}_{position}"),
)
)
steps.append(record)
if stopped == STOP_REPEAT:
return AgentResult(question, REPEAT_ANSWER, steps, STOP_REPEAT)
对照第一节的四步看:bind_tools 是"给模型说明书",invoke 后读 tool_calls 是"听它点菜",_run_tool 是"去炒菜",ToolMessage 追加后再进下一轮是"把菜端回去让它摆盘"。循环只有两个出口:模型不要工具了(正常),或者护栏叫停(兜底)。
_run_tool 里还包了一层 try/except,这是第二道防线------工具函数已经承诺"不抛异常",但工具迟早换成查数据库、调 HTTP 的版本,那时它不一定守得住承诺。兜住它,一次工具崩溃只影响这一步,不会连累整轮对话。
每一步我都记进 StepRecord:模型想调什么(intent)、工具回了什么(observations)。这些档案是 demo 的输出,也是排查线上问题的第一现场。
七、agent.py 之二:run_react,预置图替你省了什么
python
def run_react(
question: str,
llm: BaseChatModel,
tools: Sequence[BaseTool] | None = None,
*,
system: str = SYSTEM_PROMPT,
recursion_limit: int = RECURSION_LIMIT,
) -> AgentResult:
pool = list(tools) if tools is not None else TOOLS
agent = create_react_agent(llm, pool, prompt=system)
result = agent.invoke(
{"messages": [HumanMessage(content=question)]},
{"recursion_limit": recursion_limit},
)
messages = list(result["messages"])
answer = _text_of(messages[-1]) if messages else ""
return AgentResult(question, answer, _records_from(messages), STOP_FINAL)
LangGraph 的 create_react_agent 把 run_manual 那三十行全部接管:bind_tools、tool_calls 解析、ToolMessage 组装、循环退出判断,都是预置图的内置件。四行调用,同一条脚本喂进去,答案和手写版一字不差(测试里有专门的对照用例)。
**那什么时候还得手写?**当你需要:自定义停止条件("涉及退费的工具必须人工确认后才执行")、逐步审计落库、按业务动态换工具清单、或者像本篇这样在循环里插自己的护栏逻辑。循环不在你的代码里,你就看不见它------我第一次用预置图时模型在里头转了十几轮才发现,就是因为循环藏在框架里。先手写一遍再决定用不用现成的,你至少得知道轮子里有什么。
顺带交代一个环境事实:装 langgraph-prebuilt 1.x 时,create_react_agent 会报一条 "moved to langchain.agents" 的弃用警告,v2 才移除。本系列没有装 langchain 主包,照旧用 langgraph 这条通路即可,别被警告吓到去乱改依赖。
八、跑起来:真实输出
python demo.py(离线:工具是真的执行了,只有"模型决定调谁"由脚本预置)。问题二"我明天(2026-07-25)有什么课?顺便告诉我学到哪了"的完整过程:
css
── run_manual(手写循环)──
问:我明天(2026-07-25)有什么课?顺便告诉我我现在学到哪了。
答:7 月 25 日(周六)你有两节课:09:00-10:30 雅思写作精讲(王老师,3 号教室),19:00-20:00 口语陪练(外教 Anna,线上)。另外你已学 42/80 课时,进度 52%。
停止原因:final
第 1 步 · 意图 get_schedule(student_id='S1001',date='2026-07-25') + get_learning_record(student_id='S1001')
← S1001 · 2026-07-25(周六)共 2 节课
← 1. 09:00-10:30 雅思写作精讲 · 王老师 在 3 号教室
← 2. 19:00-20:00 口语陪练 · 外教 Anna 在 线上
← S1001 · 张一鸣(雅思冲刺班)
← 已学 42 / 80 课时,进度 52%
← 最近一次测评:2026-07-20,写作 6.0 / 口语 6.5
← 薄弱项:听力地图题、写作 Task2 开头段
注意"第 1 步"里模型一口气点了两个工具------同一轮里的并行 tool_calls,我的循环是逐个执行、逐条回传。所有时间、教室、百分比全部来自 tools.py 里的字典,模型一个字没编。
再看问题三,也就是事故现场的重演:
ini
── run_manual(手写循环)──
问:下周三我有课吗?(学号 S1001)
答:我没法确认「下周三」具体是几号,请你告诉我日期(比如 2026-07-29),我再帮你查。
停止原因:final
第 1 步 · 意图 get_schedule(student_id='S1001',date='下周三')
← 日期「下周三」格式不对,需要 YYYY-MM-DD,例如 2026-07-25。
如果学生说的是「明天」「下周三」,请先换算成具体日期再调用。
模型把"下周三"原样传了进来(真模型就是这么干的),工具回了格式提示而不是异常,模型读着提示老实承认了"我没法确认"------没有再编。
九、测试:38 个用例,全程离线
../_venv/bin/python -m pytest --capture=no -p no:cacheprovider -q:
arduino
tests/test_agent_manual.py::test_single_round_trip PASSED
tests/test_agent_manual.py::test_two_rounds_are_both_recorded PASSED
tests/test_agent_manual.py::test_parallel_tool_calls_in_one_step PASSED
tests/test_agent_manual.py::test_tool_result_reaches_the_next_request PASSED
tests/test_agent_manual.py::test_tool_schema_is_bound_to_the_model PASSED
tests/test_agent_manual.py::test_max_steps_stops_the_loop PASSED
tests/test_agent_manual.py::test_repeated_call_is_blocked PASSED
tests/test_agent_manual.py::test_unknown_tool_name_does_not_crash PASSED
tests/test_agent_manual.py::test_tool_exception_is_contained PASSED
tests/test_agent_manual.py::test_model_may_answer_without_tools PASSED
tests/test_agent_react.py::test_react_runs_a_single_tool PASSED
tests/test_agent_react.py::test_react_really_executed_the_tool PASSED
tests/test_agent_react.py::test_react_produces_tool_messages PASSED
tests/test_agent_react.py::test_react_matches_manual_on_the_same_script PASSED
tests/test_loop_guard.py::test_step_limit_raises PASSED
tests/test_loop_guard.py::test_first_call_is_never_repeated PASSED
tests/test_loop_guard.py::test_same_args_twice_is_repeated PASSED
tests/test_loop_guard.py::test_different_args_are_not_repeated PASSED
tests/test_loop_guard.py::test_call_key_ignores_argument_order PASSED
tests/test_loop_guard.py::test_repeat_notice_carries_previous_result PASSED
tests/test_registry.py::test_registry_has_three_tools PASSED
tests/test_registry.py::test_get_tool_hit PASSED
tests/test_registry.py::test_get_tool_unknown_raises PASSED
tests/test_registry.py::test_snapshot_has_three_fields PASSED
tests/test_registry.py::test_every_schema_has_parameters PASSED
tests/test_registry.py::test_every_parameter_has_a_description PASSED
tests/test_registry.py::test_required_params_match_expectation PASSED
tests/test_tools.py::test_three_tools_are_built PASSED
tests/test_tools.py::test_lookup_word_hit PASSED
tests/test_tools.py::test_lookup_word_miss_is_friendly PASSED
tests/test_tools.py::test_get_schedule_hit PASSED
tests/test_tools.py::test_get_schedule_unknown_student PASSED
tests/test_tools.py::test_get_learning_record_hit PASSED
tests/test_tools.py::test_bad_input_returns_friendly_text[empty-word] PASSED
tests/test_tools.py::test_bad_input_returns_friendly_text[date-says-tomorrow] PASSED
tests/test_tools.py::test_bad_input_returns_friendly_text[date-impossible] PASSED
tests/test_tools.py::test_bad_input_returns_friendly_text[no-class-that-day] PASSED
tests/test_tools.py::test_tools_use_the_data_they_were_built_with PASSED
38 passed, 4 warnings in 0.65s
38 个全绿,不联网、不要 Key(那 4 条 warning 就是第七节说的 create_react_agent 弃用提示)。
这组测试里我最想让你记住的是 test_tool_result_reaches_the_next_request:它用一个会存档的假模型,断言工具返回的"B2 级"和例句原文真的出现在了模型第二次收到的消息列表里 。工具跑完了不等于模型看到了------如果 ToolMessage 忘了追加,模型下一轮照样一问三不知,然后凭印象补一个答案,事故就回来了。test_react_matches_manual_on_the_same_script 同样值得看:同一条脚本喂两条实现,答案和观察记录必须一致,不一致说明其中一条漏了东西。
十、踩坑清单
- 裸用
@tool,docstring 里的Args:段被静默丢弃 。langchain-core 1.x 的parse_docstring默认是 False,schema 里只剩参数名和类型,参数说明整段消失,模型开始乱填参数------而代码不报任何错。必须写@tool(parse_docstring=True),并且像本篇那样用测试盯住"每个参数都有 description"。 - 工具里抛异常,整个循环跟着炸 。一次拼错的日期就把整轮对话变成"系统错误"。工具内部一律把非法参数转成友好文本返回;agent 的
_run_tool里再兜一层 try/except 当第二道防线。 - 模型把"明天""下周三"原样传给
date参数。约束写不进类型注解,只能写进参数说明("必须是 YYYY-MM-DD,例如 2026-07-25")和工具的报错文本里,两处都要有,缺一处它就重演。 - 自带的 FakeListChatModel 跑不了工具通路。它没实现 bind_tools,langchain-core 1.x 里基类那个直接抛 NotImplementedError。我照第 4 篇的做法自己写了个二十行的脚本假模型,顺带存档每次收到的消息------没有存档,"结果有没有回到模型手里"就只能靠猜。
- bind_tools 返回了副本,脚本下标被打回 0 。假模型的 bind_tools 如果
return self.bind(...)之类返回新对象,内部计数器归零,每一步都吐脚本第一条,多轮用例永远走不到第二步。要就地改标记并return self。 - 没有护栏的循环是最贵的 bug。步数上限和重复调用检测必须一开始就写进循环,而不是等账单教你。模型卡住时不报错,只是反复用同样的参数调同一个工具。
- ToolMessage 的
tool_call_id对不上会直接被接口拒收 。并行多个 tool_calls 时要逐个对应着回;模型偶尔不给 id,我用call_{步数}_{序号}兜底生成,保证消息列表永远合法。
十一、本篇收尾
回头看开头那两句话术:"周三下午三点"和"C1 级"。它们的共同点不是错,而是模型根本无权回答。这一篇之后,小E面对等级、时间、成绩这三类问题,会先说"我想调哪个工具",然后拿到你的数据说话;查不到就承认查不到,顺便告诉学生缺什么信息。回答的每个数字都能在数据里找到出处------这才配叫助教。
下一篇解决另一个上线必炸的问题:**工具要调远端接口了,超时、限流、网络抖动怎么办?**我们给 Agent 装上重试、超时和降级------让它坏得慢一点、坏得体面一点。
最后一句:模型从不缺想象力,缺的是边界------工具调用的全部意义,就是把"它以为的答案"换成"你数据里的答案"。