ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Python 智能体开发实战 07:让它查课表它张口就编?给 Agent 接上真正能动手的工具

Python 智能体开发实战 07:让它查课表它张口就编?给 Agent 接上真正能动手的工具

上周出了一次挺丢人的事故。一个学生问助教小E:"我明天的课是几点?"小E秒回:"您的写作课在周三下午三点,3 号教室。"学生第二天下午三点到了 3 号教室——那天根本没排课,写作课是周六上午的。

另一个学生问"accord 这个词在你们词表里是几级",小E答得更有底气:"C1 级,考研核心词,建议精读。"实际上它在词表里是 B2,考研大纲里压根没有它。

学生白跑一趟还是小事。带队老师看了聊天记录问我:这模型是不是坏了?

模型没坏,它甚至表现得很"称职"——生成一句像模像样的回答本来就是它唯一会做的事。它没有我们的课表,没有我们的词表,你却要求它回答里面的问题,它只能拿训练时见过的模式补全一个"最像答案的文本"。前六篇我们一直在打磨它"怎么说",这一篇要解决的是更根本的问题:让它闭嘴去查。

一、先扫盲:工具调用到底是谁在干活

市面上太多文章把工具调用写成"模型自己去查了课表",这是最大的误导。真实流程是四步,一步都绕不开人写的代码:

  1. 你的程序把问题连同工具清单一起发给模型;
  2. 模型不执行任何东西,它只回一行结构化意图:“我想调get_schedule,参数是student_id=S1001, date=2026-07-25”;
  3. 你的程序解析这行意图,去真的执行,把结果包成一条 ToolMessage 追加进消息列表;
  4. 再问一轮模型,它看着结果组织人话。

所以准确的说法是:**模型是动嘴的,你的程序是动手的。**模型从头到尾没碰过你的数据库,它只是"点菜",炒菜、端菜全是你的代码。想通这一点,后面所有的工程决策都顺理成章——工具的可靠性、安全性、报错方式,全都取决于你的代码怎么写,跟模型关系不大。

工具 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_docstring=True)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}」不是一个真实存在的日期,请核对年月日后重新查询。"own=schedules.get(sid)ifownisNone:return_not_found_student(sid)lessons=own.get(day)or[]ifnotlessons:returnf"{sid}在{day}({weekday})没有排课,可以安排自习或预约补课。"lines=[f"{sid}·{day}({weekday})共{len(lessons)}节课"]fori,lessoninenumerate(lessons,start=1):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_docstring=True)而不是裸@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]|None=None)->BaseTool:pool=list(tools)iftoolsisnotNoneelseTOOLSforiteminpool:ifitem.name==name:returnitemraiseKeyError(f"工具{name}不存在,当前可用:{[t.namefortinpool]}")defschema_snapshot(tools:Sequence[BaseTool]|None=None)->list[dict[str,Any]]:"""导出工具 schema 快照:name / description / parameters 三件套。"""pool=list(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_docstring=True,参数说明就从 schema 里消失,模型随即开始乱填参数——这种 bug 在代码 review 里根本看不出来,用例一跑就红。

五、loop_guard.py:凡是循环,先回答"它一定会停吗"

工具调用把"一次问答"变成了"一个循环",循环必须配护栏。我遇到过一次真实的打转:学生说"下周三",模型不知道今天几号,连着三轮调get_schedule(student_id="S1001", date="下周三"),每次拿到同一句"日期格式不对",然后原样重试——三轮下来烧了三倍 token,学生看到同一句错误连发三遍。

DEFAULT_MAX_STEPS=4# 三个工具,最多三轮查证 + 一轮组织语言defcall_key(name:str,args:Mapping[str,Any])->str:"""把"工具名 + 参数"压成一个稳定的字符串,用作去重键。"""returnjson.dumps({"name":name,"args":dict(argsor{})},sort_keys=True,ensure_ascii=False)classLoopGuard:defnext_step(self)->int:"""开始新的一步:步数 +1,超限就抛。"""ifself.steps>=self.max_steps:raiseStepLimitExceeded(f"已达步数上限{self.max_steps},强制收工")self.steps+=1returnself.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:previous=self._seen.get(call_key(name,args),"(上次结果已丢失)")returnREPEAT_NOTICE.format(name=name,previous=previous)

两个细节。json.dumps(sort_keys=True)是为了让"参数一样但键序不同"的两次调用判成重复——模型传参的键序真的不稳定。重复的处置方式是"回提示"而不是"抛异常":把上次结果再贴一遍,告诉它"要么换参数,要么直接回答",模型有机会自救;只有步数耗尽才由上层强制收工。护栏是来劝架的,不是来掀桌的。

六、agent.py 之一:run_manual,手写那三十行

defrun_manual(question:str,llm:BaseChatModel,tools:Sequence[BaseTool]|None=None,*,max_steps:int=DEFAULT_MAX_STEPS,system:str=SYSTEM_PROMPT,)->AgentResult:pool=list(tools)iftoolsisnotNoneelseTOOLS by_name={tool.name:toolfortoolinpool}# 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]=[]whileTrue:# 先过步数护栏:每一步都是真金白银的模型调用,不能无限try:index=guard.next_step()exceptStepLimitExceeded:returnAgentResult(question,FALLBACK_ANSWER,steps,STOP_MAX_STEPS)ai=llm_with_tools.invoke(messages)messages.append(ai)calls=list(getattr(ai,"tool_calls",None)or[])ifnotcalls:# 模型不再要工具 → 它这次给的就是最终回答returnAgentResult(question,_text_of(ai),steps,STOP_FINAL)record=StepRecord(index=index,tool_calls=[dict(item)foritemincalls])stopped:str|None=Noneforposition,iteminenumerate(calls):name=str(item.get("name")or"")args=dict(item.get("args")or{})ifguard.is_repeated(name,args):# 重复调用:不执行,回一段提示让模型知道它打转了observation=guard.repeat_notice(name,args)stopped=STOP_REPEATelse: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")orf"call_{index}_{position}"),))steps.append(record)ifstopped==STOP_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]|None=None,*,system:str=SYSTEM_PROMPT,recursion_limit:int=RECURSION_LIMIT,)->AgentResult:pool=list(tools)iftoolsisnotNoneelseTOOLS 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])ifmessageselse""returnAgentResult(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_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 里的字典,模型一个字没编。

再看问题三,也就是事故现场的重演:

── 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:

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同样值得看:同一条脚本喂两条实现,答案和观察记录必须一致,不一致说明其中一条漏了东西。

十、踩坑清单

  1. 裸用@tool,docstring 里的Args:段被静默丢弃。langchain-core 1.x 的parse_docstring默认是 False,schema 里只剩参数名和类型,参数说明整段消失,模型开始乱填参数——而代码不报任何错。必须写@tool(parse_docstring=True),并且像本篇那样用测试盯住"每个参数都有 description"。
  2. 工具里抛异常,整个循环跟着炸。一次拼错的日期就把整轮对话变成"系统错误"。工具内部一律把非法参数转成友好文本返回;agent 的_run_tool里再兜一层 try/except 当第二道防线。
  3. 模型把"明天""下周三"原样传给date参数。约束写不进类型注解,只能写进参数说明(“必须是 YYYY-MM-DD,例如 2026-07-25”)和工具的报错文本里,两处都要有,缺一处它就重演。
  4. 自带的 FakeListChatModel 跑不了工具通路。它没实现 bind_tools,langchain-core 1.x 里基类那个直接抛 NotImplementedError。我照第 4 篇的做法自己写了个二十行的脚本假模型,顺带存档每次收到的消息——没有存档,"结果有没有回到模型手里"就只能靠猜。
  5. bind_tools 返回了副本,脚本下标被打回 0。假模型的 bind_tools 如果return self.bind(...)之类返回新对象,内部计数器归零,每一步都吐脚本第一条,多轮用例永远走不到第二步。要就地改标记并return self。
  6. 没有护栏的循环是最贵的 bug。步数上限和重复调用检测必须一开始就写进循环,而不是等账单教你。模型卡住时不报错,只是反复用同样的参数调同一个工具。
  7. ToolMessage 的tool_call_id对不上会直接被接口拒收。并行多个 tool_calls 时要逐个对应着回;模型偶尔不给 id,我用call_{步数}_{序号}兜底生成,保证消息列表永远合法。

十一、本篇收尾

回头看开头那两句话术:“周三下午三点"和"C1 级”。它们的共同点不是错,而是模型根本无权回答。这一篇之后,小E面对等级、时间、成绩这三类问题,会先说"我想调哪个工具",然后拿到你的数据说话;查不到就承认查不到,顺便告诉学生缺什么信息。回答的每个数字都能在数据里找到出处——这才配叫助教。

下一篇解决另一个上线必炸的问题:**工具要调远端接口了,超时、限流、网络抖动怎么办?**我们给 Agent 装上重试、超时和降级——让它坏得慢一点、坏得体面一点。


最后一句:模型从不缺想象力,缺的是边界——工具调用的全部意义,就是把"它以为的答案"换成"你数据里的答案"。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进