049、使用Pydantic定义Agent数据结构

发布时间:2026/9/13 2:06:06
049、使用Pydantic定义Agent数据结构 049、使用Pydantic定义Agent数据结构昨天调一个Agent的tool callingJSON Schema校验死活过不去。我让模型输出一个包含“天气查询”参数的调用结果它把temperature传成了字符串而我的函数签名里写的是float。最气人的是报错信息不是“类型错误”而是“Field required”——因为我在Pydantic模型里用了Optional[float]但没给默认值。那一刻我才意识到把Agent的输入输出结构交给Pydantic不只是为了“好看”而是为了让边界条件在运行前就暴露出来。很多人写Agent数据结构用的是裸字典defrun_agent(user_input:str)-dict:messages[{role:user,content:user_input}]responsellm.chat(messages)return{reply:response,tool_calls:response.tool_calls}看起来没问题但一旦Agent开始调用多个工具返回的字典里可能缺tool_calls键也可能多出metadata。你只能靠if tool_calls in result这种防御式写法一层层包。这不是写代码这是打地鼠。我更习惯把Agent的输入、中间状态、最终输出全部定义成Pydantic模型。Agent本质上是一个有状态的对象状态需要被约束。比如一个典型的ReAct Agent它的思考记录长这样frompydanticimportBaseModel,FieldfromtypingimportLiteral,Union,ListclassThought(BaseModel):step:intField(...,description当前推理步数从0开始)content:strField(...,description模型生成的思考文本)action:Union[Literal[search,calculator,sql_query],None]Field(defaultNone,description要调用的工具名如果不调用则为None)action_input:Union[dict,None]Field(defaultNone,description传给工具的参数必须是dict)这里action_input我故意设计成dict而不是具体模型是因为不同工具的参数千差万别。但如果你在写一个垂直Agent比如只做SQL查询那action_input最好用具体模型classSQLQueryInput(BaseModel):sql:strField(...,descriptionSQL语句)limit:intField(5,ge1,le100,description返回行数默认5)classSQLThought(Thought):action:Literal[sql_query]sql_queryaction_input:SQLQueryInput这样写有个巨大的好处当LLM返回的JSON里limit是“50”字符串Pydantic会自动尝试类型转换。如果值超出1到100它立刻抛异常。你能在日志里看到是哪个字段、哪个值、为什么失败。而不是等到SQL执行到一半才报错。但别以为Pydantic会自动把所有字符串转成数字。默认情况下int字段接收字符串“50”确实能转成功但float字段接收字符串“3.14”也能转。可如果字符串是“abc”它就抛错。问题是我们经常会遇到模型输出action_input: {limit: 五十}这种诡异内容Pydantic直接抛ValidationError你的Agent循环必须捕获这个异常然后把它作为错误信息回传给LLM让它重新生成。这个闭环设计是Agent稳定性的关键。我曾经犯过一个错误把所有的工具调用结果都塞进一个List[dict]然后存进ConversationHistory。后来需要统计某个工具的平均耗时只能遍历字典判断tool_name。这太蠢了。应该直接用Pydantic的Field或者discriminator做多态。比如classToolCallRecord(BaseModel):tool_name:strduration_ms:intargs:dictclassSearchRecord(ToolCallRecord):tool_name:Literal[search]query:strresults_count:intclassCalculatorRecord(ToolCallRecord):tool_name:Literal[calculator]expression:strresult:float然后用带discriminator的模型frompydanticimportTaggedUnionclassAgentStep(BaseModel):step_id:intrecord:TaggedUnion({search:SearchRecord,calculator:CalculatorRecord})这样当你拿到AgentStep实例时record已经是具体的SearchRecord或CalculatorRecord直接用record.query访问不用再做类型判断。这里踩过坑TaggedUnion的写法在Pydantic V2里和V1不一样V1用Field(discriminatortool_name)V2我用TaggedUnion但更推荐直接把这些模型放进Union里然后让Pydantic自动判别。具体看你的版本别盲目抄网上的旧代码。回到Agent本身。Agent的状态循环里最需要被定义的数据结构是“当前步骤的结果”。我通常这样写classAgentState(BaseModel):task:strstep_index:int0max_steps:int10thoughts:List[Thought]Field(default_factorylist)final_answer:Union[str,None]Noneerror:Union[str,None]None然后Agent的每一步都基于这个state更新而不是直接改字典。更新时用model_copy(update...)保持不可变性方便追踪statestate.model_copy(update{step_index:state.step_index1,thoughts:state.thoughts[new_thought]})这里有个细节thoughts是列表model_copy(update...)不会深拷贝所以如果你把new_thought直接追加到原列表再赋给新state可能会影响旧state的引用。用state.thoughts [new_thought]创建新列表原state就不受污染。这个坑我踩了两次一次在Agent重试逻辑里一次在写单元测试时。再聊聊LLM输出解析。现在主流模型都支持response_format{type: json_object}但返回的JSON有时候不是标准JSON比如带三引号注释或者单引号。Pydantic本身不负责解析JSON字符串你得先json.loads再Model.model_validate_json。我建议直接用model_validate_json它比json.loads更宽容能处理一些编码问题try:thoughtThought.model_validate_json(raw_llm_output)exceptValidationErrorase:error_payload{role:system,content:fJSON parse error:{e}. Please fix your output.}messages.append(error_payload)# 重新调用LLM最多重试3次这里别写except Exception——你会把KeyboardInterrupt也吞了。精确捕获ValidationError然后针对错误内容做提示。LLM看到错误提示后大概率能自我纠正。如果你发现模型总是错在同一个字段比如action拼写为search_web那就是你的工具名设计有歧义要在prompt里强调可用值列表或者用Literal限制后把错误信息中的“enum”提示一起回传。还有一类坑是Optional与Union混用。很多人写classToolResult(BaseModel):data:Union[str,dict,list]|NoneNone这等于没有约束。我更建议用Any或JsonValue显式说明“这里放任意JSON类型”但别用裸Optional。如果某个字段是可选的一定要想清楚是“缺少”还是“值为空”classAgentResponse(BaseModel):reply:strField(...,descriptionAI的回复文本)tool_used:Union[str,None]Field(defaultNone,description使用的工具名没有则为None)tool_result:Union[str,None]Field(defaultNone,description工具的原始输出没有则为None)当tool_usedNone时前端就能知道这次回复没有工具调用而不是靠判断tool_result是否为空字符串。最后分享一个经验别把Pydantic模型当作数据库ORM来用。Pydantic只负责运行时数据校验不负责持久化。我在项目里见过同事把整个对话历史塞进一个Pydantic模型然后直接存进MongoDB导致字段冗余且迁移困难。正确做法是用Pydantic定义API边界的数据结构内部存储用普通字典或者专用ORM。Agent的工具调用参数用Pydantic校验校验通过后再传给真实的函数。这相当于在LLM和你自己写的代码之间加了一道严防死守的防线。写这套系列文章时我一直在试一个本地Agent框架。每次让LLM输出函数调用参数我就在想如果这个世界没有Pydantic我得写多少层if type(x) str: x float(x)。现在这些校验代码全被Pydantic的报错信息替代了。花点时间把Agent的状态模型、工具参数模型定义清楚后面调试Agent行为时你会感谢当初那个愿意多写几行类的自己。