
上篇我解决了第一道坎:让大模型的输出从一段自然语言,变成能直接 .name、.skills 的 Python 对象。
但上线跑了几条真实简历后,第二道坎立刻出现了。
模型返回了这么一个 skills:
["Python", " ", "Flink"] # 中间混了个空格字符串还能返回更离谱的——简历里压根没写姓名,它却给你编一个。
那一刻我意识到:光让输出"长得像对象"不够,还得让这个对象"干净、可信、能干活"。今天这篇就是顺着这道坎,从结构化输出一路走到 Tool,把大模型真正变成一个可约束、可校验、可复用的工程零件。
一开始我也有疑问:Agent 框架这么多,凭啥先学这个?
今天补了一个系统视角。我把几个主流框架摆在一起比,差别一下就清楚了:
框架 | 更关注什么 |
|---|---|
PydanticAI | 类型安全、结构化输出、Pythonic 工程化 |
LangGraph | 多步骤流程、状态机、复杂 Agent 编排 |
CrewAI | 多角色协作,像一个虚拟团队 |
LangChain | 生态组件多,适合 RAG 和各种集成 |
OpenAI Agents SDK | OpenAI 生态内的 Agent、工具、追踪 |
这张表治好了我的选择困难:它们不是谁比谁强,是关注点不同。
我当下最需要的,是把 LLM 输出变成稳定的 Python 对象,而不是一上来就搞多 Agent 流程编排。这就好比学开车,我现在该先把手动挡的离合油门踩明白,而不是急着学车队调度。
所以先 PydanticAI,等哪天真要做复杂编排,再去碰 LangGraph。
上篇的 ResumeSummary 只定义了字段,今天我加了一道清洗关:
from pydantic import BaseModel, Field, field_validator
class ResumeSummary(BaseModel):
name: str = Field(description="候选人姓名")
summary: str = Field(description="一句话总结候选人的核心背景")
skills: list[str] = Field(min_length=1, description="从简历中抽取出的技能")
@field_validator("skills")
@classmethod
def clean_skills(cls, skills: list[str]) -> list[str]:
return [skill.strip() for skill in skills if skill.strip()]关键点:clean_skills 不是你手动调用的,而是 Pydantic 在创建对象时自动触发。
当我写下:
ResumeSummary(
name="李明",
summary="资深数据工程师,具备 AI 和数据平台经验。",
skills=["Python", " ", "Flink"]
)Pydantic 会自动跑校验,把 ["Python", " ", "Flink"] 清洗成 ["Python", "Flink"]。
它干两件事:
[" ", ""],清洗完变空列表,而 min_length=1 会直接报错拒绝。这正好是上一篇说的"业务契约"的执行层——契约不光要写,还要有人查。
@classmethod 和 @field_validator 绑在一起,第一次看容易懵。今天我想通了一个记法:
self —— 对象已经存在,你在对做好的菜下指令。cls —— 对象还没完全生成,厨房还在备料,先让"厨房"(类)来处理。Pydantic 的校验就发生在"对象正在被创建"的过程里,对象本身还没出生,所以只能用 cls,不能用 self。
一句话:self 是成品,cls 是产线。
今天最值钱的一个认知,是把三种约束摆成了时间线:
层级 | 角色 | 作用 |
|---|---|---|
| 事前约束 | 规定模型"该做什么、边界在哪" |
| 结构约束 | 规定输出"长什么样" |
| 事后验收 | 规定数据"干不干净、合不合规" |
模糊的写法长这样:
instructions="帮我分析一下简历"工程化的写法要包含角色 + 任务 + 规则 + 边界:
instructions=(
"你是一个简历结构化助手。"
"任务是从简历原文抽取姓名、职业背景总结和技能清单。"
"必须严格基于原文,不允许编造原文没有的信息。"
"skills 必须是技能词列表,不要写成一整句话。"
)instructions 管住模型的嘴,output_type 管住输出的形,validator 管住数据的质。三层叠起来,模型的自由发挥就被框在了一个可信区间里。
真实简历经常缺字段。以前我会担心:模型会不会硬编一个名字填进去?
今天我把"不确定性"也结构化了:
class ResumeSummary(BaseModel):
name: str
confidence: float
missing_fields: list[str]当简历里没有姓名时,它不该编造,而是输出:
{
"name": "unknown",
"missing_fields": ["name"],
"confidence": 0.65
}这背后是一条很重要的工程原则:
AI 系统不应该假装自己什么都知道,它应该能结构化地表达"不知道"。
confidence 和 missing_fields 就是把"不确定"从模型的脑子里,搬到了你的程序能读到的字段里。下游代码看到 missing_fields 非空,就知道该走人工补录,而不是拿假数据入库。
今天我自己总结了最关键的一句话:
工程化的第一步,是把脚本逻辑提炼成函数。
能跑的脚本长这样:
result = agent.run_sync(resume_text)
print(result.output)这只是 Demo——一次性、不可复用。把它提成函数,性质就变了:
def analyze_resume(resume_text: str) -> ResumeSummary:
result = agent.run_sync(resume_text)
return result.output这个函数之后可以被四样东西复用:
能跑起来只是 Demo,能被函数调用才开始接近工程。 这句话我打算贴在显示器上。
最后到了今天的高潮——Tool。
核心思想一句话:把普通 Python 函数注册给 Agent,让它在需要时自己调用。
@agent.tool_plain
def classify_skill(skill: str) -> str:
...有了它,Agent 不用自己猜"Python 算后端还是数据技能",而是直接调用这个确定性函数去判。
我一口气挂了三个:
classify_skill(skill) —— 技能分类score_project(project_text) —— 项目质量评分detect_resume_risk(resume_text) —— 简历风险识别这里的关键分工是:
Agent 负责理解和组织任务,Python 函数负责确定性判断。
需要想通一个容易踩的坑:你挂了 score_project,但最终 JSON 里却看不到评分。为什么?因为 Tool 不是输出字段,它是 Agent 可以调用的能力。只有当三件事同时满足,评分才会进结果:
instructions 明确要求把评分写回结果。所以 Tool 像工具箱里的扳手——你带了不等于在用,用不用、用完放不放进收纳盒,是两码事。
今天的核心闭环串起来是这样一条链:
BaseModel 定义结构 → Field 定义字段约束 → field_validator 清洗校验 → instructions 约束行为 → missing_fields 表达不确定 → 函数封装让代码可复用 → Tool 让 Agent 调用 Python 函数。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。