前一阶段我完成了一个可测试的 LLMClient能调用 DeepSeek也能通过 Fake Model 做不访问网络的单元测试。但做到这里它仍然只是一个聊天程序还不能算真正的 Agent。普通聊天的链路很短用户问题 → LLM → 文本回答加入工具以后链路变成了用户问题 → LLM 判断是否需要工具 → 选择工具并生成参数 → Python 执行工具 → 把工具结果交回 LLM → LLM 生成最终回答链路一长测试问题也跟着多了工具本身算得对不对模型有没有选错工具参数有没有传错工具报错后 Agent 会不会直接崩掉最后的回答是否真的使用了工具结果这一阶段我没有急着接更多框架而是先实现了一个最小 Tool Agent然后从测试开发的角度把每一层拆开验证。本文记录这次实践包括一些真实踩坑和最后发现的回答质量问题。一、先准备两个足够简单的工具为了让 Agent 真正面对“选择哪个工具”的问题我准备了两个能力不同的工具乘法计算工具员工剩余年假查询工具。1. 乘法工具defmultiply_numbers(a:float,b:float)-float:用于计算两个数的乘积returnfloat(a*b)这个工具很简单但它可以用来验证模型是否能正确生成两个数值参数。2. 年假查询工具LEAVE_BALANCES{E001:5,E002:0,E003:12,E004:3,}defget_leave_balance(employee_id:str)-int:根据员工编号查询剩余年假天数ifnotemployee_idornotemployee_id.strip():raiseValueError(employee_id不能为空)normalized_employee_idemployee_id.strip().upper()ifnormalized_employee_idnotinLEAVE_BALANCES:raiseKeyError(员工不存在)returnLEAVE_BALANCES[normalized_employee_id]这里暂时用字典模拟企业内部系统。虽然不是真实数据库但已经能覆盖正常查询、大小写归一化、空编号和员工不存在等场景。我把工具先写成普通 Python 函数而不是直接和 Agent 框架绑死。这样可以先验证业务逻辑再单独验证 Agent 适配层。纯函数层工具自己的逻辑是否正确 Agent 层模型是否正确选择并调用工具如果最终答案错了这种分层能帮助判断到底是工具算错了还是 Agent 选错了。二、工具函数的名称、类型注解和 docstring对普通函数来说下面三项主要是为了代码可读性defmultiply_numbers(a:float,b:float)-float:用于计算两个数的乘积但对 Agent Tool 来说它们会直接影响模型行为函数名会成为工具名docstring 会成为工具描述参数名和类型注解会生成参数 Schema。也就是说工具描述和类型注解不仅是开发规范也是 Agent 接口的一部分。使用StructuredTool.from_function()把普通函数注册成 LangChain Toolfromlangchain_core.toolsimportStructuredToolfromapp.toolsimportget_leave_balance,multiply_numbersdefbuild_tools()-list[StructuredTool]:return[StructuredTool.from_function(funcget_leave_balance),StructuredTool.from_function(funcmultiply_numbers),]乘法函数生成的 Schema 大致如下{description:用于计算两个数的乘积,properties:{a:{type:number},b:{type:number}},required:[a,b],type:object}模型看到这个 Schema 后才知道应该生成{name:multiply_numbers,args:{a:12,b:8}}这也是为什么工具参数不能只写成一个模糊的data或input。参数越清楚模型越容易生成正确调用。三、Tool Agent 为什么需要调用模型两次这是我开始实现时最容易混淆的地方。用户问12乘以8是多少第一次调用模型模型并不会执行项目中的 Python 函数。它只会返回一个调用计划AIMessage(content,tool_calls[{name:multiply_numbers,args:{a:12,b:8},id:call_xxx,}],)此时content为空是正常的因为模型还没有给最终答案。它只是在告诉程序请调用 multiply_numbers参数是 a12、b8。接着由本地 Python 代码真正执行工具tool_resulttool.invoke(tool_call[args])得到96.0然后把结果包装成ToolMessageToolMessage(content96.0,namemultiply_numbers,tool_call_idcall_xxx,)tool_call_id需要与第一轮模型生成的调用 ID 一致。这样模型才能知道这个结果对应哪次工具调用。最后把三条消息一起交回模型HumanMessage12乘以8是多少 AIMessage调用 multiply_numbers(a12, b8) ToolMessage96.0第二次调用模型后才得到最终回答12乘以8等于96。所以完整过程其实是阶段调用对象作用第一轮LLM选择工具并生成参数中间Python Tool执行业务逻辑第二轮LLM根据工具结果生成最终回答四、实现一个最小 Tool Agent为了让后续测试能够观察整个过程我没有让run()只返回最终字符串而是定义了一个执行结果对象fromdataclassesimportdataclassfromlangchain_core.messagesimportAIMessage,ToolMessagedataclassclassAgentRunResult:decision:AIMessage tool_messages:list[ToolMessage]final_response:AIMessage三个字段分别代表decision 第一轮模型决策 tool_messages 工具执行结果 final_response 最终模型回答Agent 的核心实现如下fromlangchain_core.messagesimportAIMessage,HumanMessage,ToolMessageclassToolAgent:def__init__(self,chat_model,tools):self.model_with_toolschat_model.bind_tools(tools)self.tools_by_name{tool.name:toolfortoolintools}defdecide(self,prompt:str)-AIMessage:returnself.model_with_tools.invoke(prompt)defexecute_tool_calls(self,message:AIMessage)-list[ToolMessage]:tool_messages[]fortool_callinmessage.tool_calls:toolself.tools_by_name[tool_call[name]]try:contentstr(tool.invoke(tool_call[args]))statussuccessexcept(ValueError,KeyError)asexc:contentstr(exc.args[0]ifexc.argselseexc)statuserrortool_messages.append(ToolMessage(contentcontent,statusstatus,nametool_call[name],tool_call_idtool_call[id],))returntool_messagesdefrun(self,prompt:str)-AgentRunResult:decisionself.decide(prompt)ifnotdecision.tool_calls:returnAgentRunResult(decisiondecision,tool_messages[],final_responsedecision,)tool_messagesself.execute_tool_calls(decision)messages[HumanMessage(contentprompt),decision,*tool_messages,]final_responseself.model_with_tools.invoke(messages)returnAgentRunResult(decisiondecision,tool_messagestool_messages,final_responsefinal_response,)当前版本只支持一轮工具调用目的是先把最小闭环跑通。多轮循环、并行调用和最大步数限制可以后续再加不需要一开始就把 Agent 做得很重。五、分层测试测试不能只盯着最终回答如果只写下面这种断言assert96infinal_answer很多问题会被漏掉。例如模型可能根本没调用工具而是直接给出了答案也可能调用了错误工具只是碰巧得到了相同结果。因此我把测试拆成四层。1. 纯工具测试直接验证业务函数pytest.mark.parametrize(a, b, expected,[(12,8,96.0),(3.5,7,24.5),(0.0,101,0.0),(-2.5,3.6,-9.0),],)deftest_multiply_numbers(a,b,expected):resultmultiply_numbers(a,b)assertresultpytest.approx(expected)assertisinstance(result,float)这一层不涉及 LLM只验证工具本身算得对不对。2. Tool Schema 契约测试工具函数能运行不代表注册给模型的接口一定正确。比如有人修改了参数名或删掉类型注解普通函数测试可能仍然通过但模型看到的 Schema 已经变了。deftest_multiply_tool_has_expected_schema():tools_by_name{tool.name:toolfortoolinbuild_tools()}multiply_tooltools_by_name[multiply_numbers]schemamultiply_tool.args_schema.model_json_schema()assertset(schema[properties]){a,b}assertset(schema[required]){a,b}assertschema[properties][a][type]numberassertschema[properties][b][type]number这一层验证 Agent 的工具契约没有被悄悄破坏。3. Fake Model 编排测试Fake Model 不是为了证明真实模型会正确选工具而是为了稳定验证自己的编排代码。构造一个假的AIMessageAIMessage(content,tool_calls[{name:multiply_numbers,args:{a:12,b:8},id:call-1,type:tool_call,}],)测试重点包括工具列表是否传给bind_tools()prompt 是否传给绑定后的模型工具名称和参数是否保留工具是否真的执行ToolMessage.tool_call_id是否和调用 ID 对应第二轮消息顺序是否正确无工具场景是否只调用模型一次。例如完整闭环测试会检查第二轮输入second_inputfake_model.received_inputs[1]assertlen(second_input)3assertsecond_input[0].content12乘以8是多少assertsecond_input[1].tool_calls[0][name]multiply_numbersassertsecond_input[2].content96.0assertsecond_input[2].tool_call_idcall-14. 真实模型路由验证Fake 中的工具名称是手工写进去的它不能证明 DeepSeek 真的会正确选择工具。因此还需要少量真实模型验证。我准备了三类问题问题预期行为12乘以8是多少调用乘法工具查询员工E001的剩余年假调用年假工具你好请介绍一下自己不调用工具实际结果全部符合预期。乘法问题工具决策: multiply_numbers(a12, b8) 工具结果: 96.0 最终回答: 12乘以8等于96。年假问题工具决策: get_leave_balance(employee_idE001) 工具结果: 5 最终回答: 员工E001的剩余年假为5天。普通聊天工具决策: [] 工具结果: [] 最终回答: 直接回复用户真实模型测试负责验证模型能力Fake 测试负责验证代码编排。两者不能互相替代。六、工具异常处理异常需要特殊处理不能让整个 Agent 直接崩掉。增加一个不存在的员工编号查询员工E999的剩余年假第一次运行时程序直接出现KeyError: 员工不存在调用链是ToolAgent.run → execute_tool_calls → StructuredTool.invoke → get_leave_balance → KeyError → 整个 Agent 终止这对最终用户显然不友好。于是我将已知的业务异常转换成状态为error的ToolMessageexcept(ValueError,KeyError)asexc:contentstr(exc.args[0]ifexc.argselseexc)statuserror这里没有直接捕获所有Exception。如果把属性拼错、代码写错等编程缺陷也全部吞掉反而会降低问题可见性。当前只处理工具明确声明的业务异常。修复后真实输出为用户问题: 查询员工E999的剩余年假 工具决策: get_leave_balance(employee_idE999) 工具结果: 员工不存在 最终回答: 查询结果显示员工E999不存在请核实员工编号。Agent 不再崩溃而且模型能根据失败的工具消息生成可理解的回答。对应的自动化测试不仅检查错误文案还检查status error name get_leave_balance tool_call_id 与原调用一致 content 包含“员工不存在”七、真实测试还发现了一个意外问题普通聊天场景没有错误调用工具路由是正确的。但模型在自我介绍时说我可以进行数学运算比如乘法、加法等 也可以查询员工年假比如查询员工E1001。这里有两个问题当前只注册了乘法工具并没有加法工具模拟数据中也没有 E1001。这说明工具路由通过不代表最终回答质量一定通过。从测试角度可以把问题拆成工具选择质量这次是否该调用工具 参数质量工具名和参数是否正确 执行质量工具结果是否正确 生成质量最终回答是否忠实于真实能力和工具结果当前项目已经覆盖前三项的基础场景。第四项需要在后续增加 System Prompt、能力边界约束和回答忠实性评估。这个问题反而让我更确定Agent 测试不能只看“程序有没有跑通”还要观察模型在最终表达中有没有夸大能力或编造数据。八、当前测试结果运行全部测试python-mpytest-q结果32 passed目前包括LLMClient 正常、超时和空输入测试乘法与年假工具纯函数测试Tool 名称、描述和参数 Schema 测试StructuredTool 调用测试Agent 工具决策、执行和两轮消息测试无工具直接回答测试工具业务异常转换测试。32条用例并不算多但已经形成了一个比较清晰的分层结构。后续新增工具时可以复用这套思路而不是把所有问题都塞进端到端测试。九、这阶段最大的收获这次实践让我对 Agent 的理解从“模型会调用函数”变成了更具体的执行链路模型只负责决策 程序负责执行工具 ToolMessage 负责把结果带回上下文 模型再负责生成最终回答从测试角度最重要的也不是断言最终答案里有没有某个数字而是保留并检查整个执行轨迹用户输入 → 工具名称 → 工具参数 → 调用 ID → 工具状态 → 工具结果 → 最终回答有了轨迹失败时才能定位是模型选错了工具 是参数生成错了 是工具执行失败 还是最终回答没有忠实使用工具结果这也是 Agent 测试与普通接口自动化测试相比比较有意思也更有挑战的地方。十、下一阶段计划Tool Agent 的最小闭环已经完成。下一步会进入企业知识助手的 RAG 链路重点实践文档加载与切分向量检索构造带标准答案和相关文档标注的测试集RecallK、PrecisionK、MRR回答忠实性与幻觉测试将 Tool、RAG 和 LLM 评估逐步汇总为质量报告。