
上周出了一次挺丢人的事故。一个学生问助教小E我明天的课是几点小E秒回您的写作课在周三下午三点3 号教室。学生第二天下午三点到了 3 号教室——那天根本没排课写作课是周六上午的。另一个学生问accord 这个词在你们词表里是几级小E答得更有底气C1 级考研核心词建议精读。实际上它在词表里是 B2考研大纲里压根没有它。学生白跑一趟还是小事。带队老师看了聊天记录问我这模型是不是坏了模型没坏它甚至表现得很称职——生成一句像模像样的回答本来就是它唯一会做的事。它没有我们的课表没有我们的词表你却要求它回答里面的问题它只能拿训练时见过的模式补全一个最像答案的文本。前六篇我们一直在打磨它怎么说这一篇要解决的是更根本的问题让它闭嘴去查。一、先扫盲工具调用到底是谁在干活市面上太多文章把工具调用写成模型自己去查了课表这是最大的误导。真实流程是四步一步都绕不开人写的代码你的程序把问题连同工具清单一起发给模型模型不执行任何东西它只回一行结构化意图“我想调get_schedule参数是student_idS1001, date2026-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 的预置实现跑一遍。二、工程目录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工具是给程序用的报错要说人话先看最核心的查课表。三个工具我用工厂函数造而不是直接写在模块顶层原因在注释里defmake_get_schedule(schedules:Mapping[str,Mapping[str,list[dict[str,str]]]],)-BaseTool:造一个查课表工具数据来自传入的字典。tool(parse_docstringTrue)defget_schedule(student_id:str,date:str)-str:查询某个学生在指定日期的课表。 当学生问「我明天/某天有课吗」「几点上课」「在哪间教室」「哪个老师」时调用本工具。 时间、教室、老师一律以本工具返回为准不要凭印象推断。 Args: student_id: 学号大写 S 开头加四位数字例如 S1001。 date: 要查的日期必须是 YYYY-MM-DD 格式例如 2026-07-25。 sid(student_idor).strip()day(dateor).strip()# 两个参数都可能是模型自作主张填出来的逐个挡回去ifnotsid:return没有拿到学号。请让学生提供学号S 开头加四位数字例如 S1001。ifnot_DATE_RE.match(day):return(f日期「{dayor空}」格式不对需要 YYYY-MM-DD例如 2026-07-25。如果学生说的是「明天」「下周三」请先换算成具体日期再调用。)try:weekday_weekday_of(day)exceptValueError:returnf「{day}」不是一个真实存在的日期请核对年月日后重新查询。ownschedules.get(sid)ifownisNone:return_not_found_student(sid)lessonsown.get(day)or[]ifnotlessons:returnf{sid}在{day}{weekday}没有排课可以安排自习或预约补课。lines[f{sid}·{day}{weekday}共{len(lessons)}节课]fori,lessoninenumerate(lessons,start1):lines.append(f{i}.{lesson.get(time,)}{lesson.get(course,)}f ·{lesson.get(teacher,)}在{lesson.get(room,)})return\n.join(lines)returnget_schedule三个刻意的决定一个比一个重要**第一用工厂函数把数据注入进去。**测试里塞两三条假数据就能断言命中与未命中上线时换成从数据库读的那份——工具的逻辑和数据是分离的这才叫工程对象不叫脚本。**第二docstring 用了tool(parse_docstringTrue)而不是裸tool。**这是我本篇踩得最深的一个坑单列在踩坑清单第 1 条langchain-core 1.x 里这个开关默认是关的Args:段不会被解析进参数 schema模型拿到的就只有一个光秃秃的{word: {type: string}}——参数说明整段丢失。后面 registry.py 里有测试专门盯这件事。**第三参数不合法时返回友好错误文本绝不抛异常。**这是工具调用里最反直觉又最要命的工程细节工具是在循环里被调用的一个 ValueError 会顺着循环一路炸到最外层学生看到的是系统错误而不是日期格式不对。更妙的是一段说清楚错在哪、该怎么改的文本喂回给模型后模型有机会当场自救——它会把明天换算成日期再调一次。抛异常则什么都救不了。报错文本本身就是给模型的提示词这个观念转过来工具就活了。另外注意返回值全是字符串还带着编号和换行——这段文本会被原样塞进 ToolMessage既给模型看也给人看日志里它是最常被翻的行。lookup_word和get_learning_record结构完全一样不再贴全文只看一眼查询未命中的话术def_not_found_word(word:str)-str:词表没命中时的统一话术。return(f词表里暂时没有「{word}」这个单词。如果你确认拼写没错可以先按通用释义讲或让教研同事把它补进分级词表。)**“没查到必须是一个明确的、可读的答案而不是 None更不是编一个。**本篇开头的周三下午三点”本质就是没有工具兜底时模型替数据发了言。四、registry.py工具清单只留一个真相TOOLS:list[BaseTool]build_tools()defget_tool(name:str,tools:Sequence[BaseTool]|NoneNone)-BaseTool:poollist(tools)iftoolsisnotNoneelseTOOLSforiteminpool:ifitem.namename:returnitemraiseKeyError(f工具{name}不存在当前可用{[t.namefortinpool]})defschema_snapshot(tools:Sequence[BaseTool]|NoneNone)-list[dict[str,Any]]:导出工具 schema 快照name / description / parameters 三件套。poollist(tools)iftoolsisnotNoneelseTOOLSreturn[{name:item.name,description:item.descriptionor,parameters:item.args_schema.model_json_schema()ifitem.args_schemaelse{},}foriteminpool]get_tool对不存在的名字直接抛 KeyError不静默兜底——名字写错是开发期就该炸的 bug拖到线上就是模型拿着错误结果推理三轮。schema_snapshot是给测试用的哪天有人把 docstring 里的Args:段删了或者忘了parse_docstringTrue参数说明就从 schema 里消失模型随即开始乱填参数——这种 bug 在代码 review 里根本看不出来用例一跑就红。五、loop_guard.py凡是循环先回答它一定会停吗工具调用把一次问答变成了一个循环循环必须配护栏。我遇到过一次真实的打转学生说下周三模型不知道今天几号连着三轮调get_schedule(student_idS1001, date下周三)每次拿到同一句日期格式不对然后原样重试——三轮下来烧了三倍 token学生看到同一句错误连发三遍。DEFAULT_MAX_STEPS4# 三个工具最多三轮查证 一轮组织语言defcall_key(name:str,args:Mapping[str,Any])-str:把工具名 参数压成一个稳定的字符串用作去重键。returnjson.dumps({name:name,args:dict(argsor{})},sort_keysTrue,ensure_asciiFalse)classLoopGuard:defnext_step(self)-int:开始新的一步步数 1超限就抛。ifself.stepsself.max_steps:raiseStepLimitExceeded(f已达步数上限{self.max_steps}强制收工)self.steps1returnself.stepsdefis_repeated(self,name:str,args:Mapping[str,Any])-bool:returncall_key(name,args)inself._seendefrepeat_notice(self,name:str,args:Mapping[str,Any])-str:previousself._seen.get(call_key(name,args),上次结果已丢失)returnREPEAT_NOTICE.format(namename,previousprevious)两个细节。json.dumps(sort_keysTrue)是为了让参数一样但键序不同的两次调用判成重复——模型传参的键序真的不稳定。重复的处置方式是回提示而不是抛异常把上次结果再贴一遍告诉它要么换参数要么直接回答模型有机会自救只有步数耗尽才由上层强制收工。护栏是来劝架的不是来掀桌的。六、agent.py 之一run_manual手写那三十行defrun_manual(question:str,llm:BaseChatModel,tools:Sequence[BaseTool]|NoneNone,*,max_steps:intDEFAULT_MAX_STEPS,system:strSYSTEM_PROMPT,)-AgentResult:poollist(tools)iftoolsisnotNoneelseTOOLS by_name{tool.name:toolfortoolinpool}# bind_tools把工具 schema 挂在模型上模型才知道自己有哪些工具可用llm_with_toolsllm.bind_tools(pool)messages:list[BaseMessage][SystemMessage(contentsystem),HumanMessage(contentquestion),]guardLoopGuard(max_steps)steps:list[StepRecord][]whileTrue:# 先过步数护栏每一步都是真金白银的模型调用不能无限try:indexguard.next_step()exceptStepLimitExceeded:returnAgentResult(question,FALLBACK_ANSWER,steps,STOP_MAX_STEPS)aillm_with_tools.invoke(messages)messages.append(ai)callslist(getattr(ai,tool_calls,None)or[])ifnotcalls:# 模型不再要工具 → 它这次给的就是最终回答returnAgentResult(question,_text_of(ai),steps,STOP_FINAL)recordStepRecord(indexindex,tool_calls[dict(item)foritemincalls])stopped:str|NoneNoneforposition,iteminenumerate(calls):namestr(item.get(name)or)argsdict(item.get(args)or{})ifguard.is_repeated(name,args):# 重复调用不执行回一段提示让模型知道它打转了observationguard.repeat_notice(name,args)stoppedSTOP_REPEATelse:observation_run_tool(by_name,name,args)guard.remember(name,args,observation)record.observations.append(observation)messages.append(ToolMessage(contentobservation,tool_call_idstr(item.get(id)orfcall_{index}_{position}),))steps.append(record)ifstoppedSTOP_REPEAT:returnAgentResult(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预置图替你省了什么defrun_react(question:str,llm:BaseChatModel,tools:Sequence[BaseTool]|NoneNone,*,system:strSYSTEM_PROMPT,recursion_limit:intRECURSION_LIMIT,)-AgentResult:poollist(tools)iftoolsisnotNoneelseTOOLS agentcreate_react_agent(llm,pool,promptsystem)resultagent.invoke({messages:[HumanMessage(contentquestion)]},{recursion_limit:recursion_limit},)messageslist(result[messages])answer_text_of(messages[-1])ifmessageselsereturnAgentResult(question,answer,_records_from(messages),STOP_FINAL)LangGraph 的create_react_agent把 run_manual 那三十行全部接管bind_tools、tool_calls 解析、ToolMessage 组装、循环退出判断都是预置图的内置件。四行调用同一条脚本喂进去答案和手写版一字不差测试里有专门的对照用例。**那什么时候还得手写**当你需要自定义停止条件“涉及退费的工具必须人工确认后才执行”、逐步审计落库、按业务动态换工具清单、或者像本篇这样在循环里插自己的护栏逻辑。循环不在你的代码里你就看不见它——我第一次用预置图时模型在里头转了十几轮才发现就是因为循环藏在框架里。先手写一遍再决定用不用现成的你至少得知道轮子里有什么。顺带交代一个环境事实装langgraph-prebuilt1.x 时create_react_agent会报一条 “moved to langchain.agents” 的弃用警告v2 才移除。本系列没有装 langchain 主包照旧用 langgraph 这条通路即可别被警告吓到去乱改依赖。八、跑起来真实输出python demo.py离线工具是真的执行了只有模型决定调谁由脚本预置。问题二我明天2026-07-25有什么课顺便告诉我学到哪了的完整过程── 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_idS1001date2026-07-25) get_learning_record(student_idS1001) ← 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 里的字典模型一个字没编。再看问题三也就是事故现场的重演── run_manual手写循环── 问下周三我有课吗学号 S1001 答我没法确认「下周三」具体是几号请你告诉我日期比如 2026-07-29我再帮你查。 停止原因final 第 1 步 · 意图 get_schedule(student_idS1001date下周三) ← 日期「下周三」格式不对需要 YYYY-MM-DD例如 2026-07-25。 如果学生说的是「明天」「下周三」请先换算成具体日期再调用。模型把下周三原样传了进来真模型就是这么干的工具回了格式提示而不是异常模型读着提示老实承认了我没法确认——没有再编。九、测试38 个用例全程离线../_venv/bin/python -m pytest --captureno -p no:cacheprovider -qtests/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.65s38 个全绿不联网、不要 Key那 4 条 warning 就是第七节说的 create_react_agent 弃用提示。这组测试里我最想让你记住的是test_tool_result_reaches_the_next_request它用一个会存档的假模型断言工具返回的B2 级和例句原文真的出现在了模型第二次收到的消息列表里。工具跑完了不等于模型看到了——如果 ToolMessage 忘了追加模型下一轮照样一问三不知然后凭印象补一个答案事故就回来了。test_react_matches_manual_on_the_same_script同样值得看同一条脚本喂两条实现答案和观察记录必须一致不一致说明其中一条漏了东西。十、踩坑清单裸用tooldocstring 里的Args:段被静默丢弃。langchain-core 1.x 的parse_docstring默认是 Falseschema 里只剩参数名和类型参数说明整段消失模型开始乱填参数——而代码不报任何错。必须写tool(parse_docstringTrue)并且像本篇那样用测试盯住每个参数都有 description。工具里抛异常整个循环跟着炸。一次拼错的日期就把整轮对话变成系统错误。工具内部一律把非法参数转成友好文本返回agent 的_run_tool里再兜一层 try/except 当第二道防线。模型把明天下周三原样传给date参数。约束写不进类型注解只能写进参数说明“必须是 YYYY-MM-DD例如 2026-07-25”和工具的报错文本里两处都要有缺一处它就重演。自带的 FakeListChatModel 跑不了工具通路。它没实现 bind_toolslangchain-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 装上重试、超时和降级——让它坏得慢一点、坏得体面一点。最后一句模型从不缺想象力缺的是边界——工具调用的全部意义就是把它以为的答案换成你数据里的答案。