写 Agent 最容易低估两件事:Prompt 怎么组织,输出怎么消费。不少团队的 Agent 在 demo 阶段跑得飞起,一进生产就问题不断——翻日志十有八九是两类:模型没理解任务,或者模型理解了、输出格式却飘了,下游 json.loads 当场炸掉。
**Agent 的可靠性,一半取决于提示词工程质量,另一半取决于输出能否被程序可靠消费。**本篇把这两件事一次讲透:前半篇讲用代码管理 Prompt 的标准姿势(ChatPromptTemplate、MessagesPlaceholder、Few-shot),后半篇讲结构化输出(Pydantic 模型、with_structured_output、校验与重试)。全文基于 langchain-core 1.6,代码可直接运行。
1、概述
先给全文一张地图。Agent 里一次"可靠的模型调用",完整链路如下:
Prompt 模板(system / few-shot / history / human)
│
▼
LLM ────── with_structured_output:按 schema 约束生成
│
▼
Pydantic 解析 ─── 失败:parsing_error ──▶ 错误反馈重试
│
▼
field_validator 校验 ─── 越界:抛错进重试 / 夹紧放行
│
▼
业务代码消费(类型安全的实例)
这条链对应 Prompt 工程的两层含义:
- 组织层:Prompt 是代码资产,要有模板、变量和版本管理,不能是散落在分支里的字符串拼接
- 契约层:LLM 的输出是程序的输入,格式必须像 API 响应一样被严格约束与校验
全文主线一句话:凡是下游代码要消费的输出,都必须结构化;凡是给人看的中间过程,可以自由文本。
2、Prompt 模板化管理
第一原则:不要在业务代码里拼 Prompt 字符串。f-string 拼接的毛病很实际:变量没有校验、消息角色硬编码、同一段话术复制得到处都是,想改一句要动五个文件。
langchain-core 的答案是 ChatPromptTemplate.from_messages(API 文档),用消息元组声明对话结构:
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.messages import AIMessage, HumanMessage
prompt = ChatPromptTemplate.from_messages([
("system", "你是电商客服助手,回答不超过三句话。"),
MessagesPlaceholder("history"), # 动态注入历史消息
("human", "{question}"), # 变量插值
])
value = prompt.invoke({
"history": [
HumanMessage(content="我的订单号是 10086"),
AIMessage(content="已记录,请问有什么问题?"),
],
"question": "刚才那个订单到哪了",
})
messages = value.to_messages() # 最终消息列表,直接喂给模型
解读:三个元组声明了消息的角色与顺序,{question} 是变量插值(默认 f-string 语法,template_format 参数可换成 jinja2),MessagesPlaceholder("history") 占位一段不定长的消息列表。invoke 返回 PromptValue,to_messages() 之后就是标准消息列表。
MessagesPlaceholder 是多轮 Agent 的记忆接口:图的状态里存消息历史,每轮渲染时透传进来,模型就有了上下文。系列第 2 篇用过的 prompt | llm 链式写法,左边拼的就是它。变量错误也在这一层拦截——invoke 缺变量会直接抛错,而不是等到线上才发现。
模板还支持 partial_variables 预填变量:把默认 persona、当前日期这类固定值先填进去,运行时只传剩余变量,调用方省心,测试也好写。
3、消息模板设计三段式
模板解决组织问题,写什么内容才是质量所在。生产可用的 system 消息,建议按"任务-约束-格式"三段组织:
SYSTEM = """你是{company}的资深数据分析师。
任务:根据用户提供的销售数据回答业务问题。
约束:
- 结论必须来自数据,数据不足时回答"数据中未提供"
- 金额统一换算为万元,保留一位小数
输出格式:先一句话结论,随后给出不超过两行的依据。"""
解读:任务段给模型定位(做什么),约束段划红线(不许做什么),格式段定输出形态(长什么样)。三段缺一都容易翻车:只给任务,模型自由发挥;只有约束没有格式,模型不知道往哪儿使劲。
几条实操经验:
- persona 要具体,"资深数据分析师"优于"聪明的助手";人设堆到十句不会线性变好,两三句到位即可
- 约束用短列表写,一行一条,模型对短列表的遵循度明显好于大段文字
- 引入结构化输出后,格式段可以整段删掉——schema 本身就是最强的格式约束,第 5 节展开
4、Few-shot 示例注入
有些格式规则用文字说不清,比如"query 字段取产品名而不是品牌名"。这时两个例子胜过十条约束,这就是 Few-shot(少样本提示):把输入-输出对以对话形式注入模板。langchain-core 提供了专用类 FewShotChatMessagePromptTemplate:
from langchain_core.prompts import (
ChatPromptTemplate,
FewShotChatMessagePromptTemplate,
)
examples = [
{"input": "苹果手机多少钱", "output": '{"query": "iPhone", "metric": "价格"}'},
{"input": "上个月华为卖得怎么样", "output": '{"query": "华为", "metric": "销量"}'},
]
example_prompt = ChatPromptTemplate.from_messages([
("human", "{input}"),
("ai", "{output}"),
])
few_shot = FewShotChatMessagePromptTemplate(
example_prompt=example_prompt, # 单条示例的渲染模板
examples=examples, # 示例列表,也可传选择器动态挑选
)
final = ChatPromptTemplate.from_messages([
("system", "把用户问题解析为 JSON,字段:query、metric。"),
few_shot, # 展开为 human/ai 交替的示例消息
("human", "{input}"),
])
解读:example_prompt 定义单条示例的形态(human 问、ai 答),FewShotChatMessagePromptTemplate 把整个列表渲染成交替消息插进最终模板。examples 也可以换成基于向量相似度的选择器,按当前输入动态挑最像的几条,而不是全量注入。
Few-shot 不是越多越好,什么时候有效要分开看:
| 场景 | 效果 | 建议 |
|---|---|---|
| 格式规则难以文字描述 | 有效 | 示例即规格,优先用 |
| 边界 case 屡教不改 | 有效 | 针对性补示例 |
| 任务简单、模型能力富余 | 负收益 | 白耗 token,删掉 |
| 示例之间互相矛盾 | 负收益 | 模型随机选边,先清理 |
经验值:示例 2~5 个起步,超过 8 个就该审视是不是该换结构化输出,或者任务本身该拆。示例还有隐性成本——模型会模仿示例的意外特征:示例都在问价格,它就可能把什么问题都往价格上靠。
示例本身要当测试数据来维护:挑真实分布的典型样本,别全用顺手编的玩具例子;上线后模型翻车的 case 回流成新示例,往往是最有效的持续改进手段。
5、结构化输出基础
进入下半篇。上面的例子还在靠示例教模型输出 JSON,正规做法是把输出 schema 显式声明出来:定义 Pydantic 模型,交给 with_structured_output(官方结构化输出指南)。
from langchain.chat_models import init_chat_model
from pydantic import BaseModel, Field
llm = init_chat_model("openai:gpt-5.1") # 换成你在用的模型
class Paper(BaseModel):
title: str = Field(description="论文标题,保留原文语言")
authors: list[str] = Field(description="作者姓名列表,按原文顺序")
year: int = Field(description="发表年份,四位整数")
structured_llm = llm.with_structured_output(Paper)
paper = structured_llm.invoke("从下面这段文本中抽取论文信息:……")
print(paper.title, paper.authors, paper.year)
解读:with_structured_output 接收 Pydantic 模型,返回一个新的 Runnable,调用方式与普通模型一致,但 invoke 拿到的不是字符串而是校验过的 Paper 实例——类型错误在解析层就被拦下,业务代码拿到手直接用,IDE 补全全程在线。
两个关键认知:
Field(description=...)不是给人看的注释,它就是 Prompt 的一部分,会被合并进发给模型的 JSON Schema。"按原文顺序"这种约束写在这里,比写在 system 消息里更有效- 底层机制分两路:支持原生结构化输出的主流模型走 JSON Schema 约束生成(默认行为),格式由模型侧保证;不支持的模型降级为 JSON mode 加输出解析,可用
method参数显式指定
有了 schema,第 3 节三段式里的"输出格式"段可以整段删掉——用类型系统描述格式,比自然语言精确一个量级。
顺带说明选型:with_structured_output 也接受 TypedDict 或裸 JSON Schema,但生产代码建议统一用 Pydantic。TypedDict 只描述形状,校验器、默认值、model_dump 序列化都得自己补,等于把类型系统的红利还回去一半。
6、嵌套模型与失败重试
6.1、嵌套 schema
真实抽取任务很少是扁平结构。经典场景"提取所有实体及其关系",需要嵌套模型加数组字段:
from pydantic import BaseModel, Field
class Entity(BaseModel):
name: str = Field(description="实体名称,使用原文")
type: str = Field(description="实体类型:人物/组织/地点/技术")
class Relation(BaseModel):
source: str = Field(description="起点实体名,必须出现在 entities 中")
target: str = Field(description="终点实体名,必须出现在 entities 中")
relation: str = Field(description="关系类型,如 任职/投资/收购/位于")
class Extraction(BaseModel):
entities: list[Entity] = Field(description="文中出现的全部实体")
relations: list[Relation] = Field(description="实体之间的关系列表")
解读:嵌套的 Pydantic 模型会生成嵌套的 JSON Schema,模型收到的是一棵完整的类型树。特别注意 source 与 target 描述里那句"必须出现在 entities 中"——跨字段引用约束写进 description 是低成本高收益的技巧,能明显减少模型编造列表里不存在的实体名。
6.2、解析失败与重试
schema 约束再强,解析依然会失败:字段类型不对、必填字段缺失、长输出被截断。with_structured_output 的 include_raw=True 让失败不抛异常,而是返回三件套 {"raw", "parsed", "parsing_error"},重试策略建立在它之上:
from langchain_core.messages import HumanMessage
llm_raw = llm.with_structured_output(Extraction, include_raw=True)
def extract_with_retry(text: str, max_retries: int = 2):
messages = [HumanMessage(content=text)]
result = llm_raw.invoke(messages)
for _ in range(max_retries):
if result["parsed"] is not None:
return result["parsed"] # 成功,返回 Extraction 实例
# 失败:把原始输出和报错回传给模型,请求修正
messages = messages + [
result["raw"],
HumanMessage(content=f"上一次输出解析失败:{result['parsing_error']}。"
"请严格遵循 schema 重新输出。"),
]
result = llm_raw.invoke(messages)
raise RuntimeError(f"重试 {max_retries} 次后仍然失败")
解读:重试的关键动作是把失败原因反馈给模型。单纯重发同样的请求,成功率提升有限;带上具体报错,一次修正重试通常就能修复大多数偶发失败。
注意:个别模型集成在解析层会直接抛异常而不是返回
parsing_error,生产代码建议在外层再套try/except,把异常同样转成修正反馈。重试上限两三次足够——超过还失败,多半是 schema 自身有矛盾(字段描述打架、枚举漏项),修 schema 比加一轮重试有用。
7、输出校验与适用边界
7.1、校验器兜底
schema 管住了形状,管不住语义:年份输出 3012、置信度输出 1.8,类型全对但业务全错。这一层用 Pydantic 校验器(validators 文档)兜底:
from pydantic import BaseModel, Field, field_validator
class Paper(BaseModel):
title: str = Field(description="论文标题")
year: int = Field(description="发表年份")
confidence: float = Field(description="抽取结果置信度,0 到 1")
@field_validator("year")
@classmethod
def check_year(cls, v: int) -> int:
if not 1950 <= v <= 2030:
raise ValueError(f"年份 {v} 越界,应在 1950~2030 之间")
return v
@field_validator("confidence")
@classmethod
def clamp_confidence(cls, v: float) -> float:
return min(max(v, 0.0), 1.0) # 超界直接夹紧,不触发重试
解读:两个校验器展示了两种态度。check_year 抛 ValueError,校验失败会以解析失败的形式暴露,自然接入上一节的重试闭环,由模型自己修;clamp_confidence 静默夹紧,小偏差不值得一次模型往返。原则一句话:模型能修的交给重试,机械能修的代码直接改。
7.2、什么时候不用结构化输出
结构化输出不是默认正确,三种场景建议保持自由文本:
- 生成本身就是目的:写文章、写邮件、生成代码注释,schema 会束缚表达
- 下游是另一个 LLM:中间环节传自然语言,比序列化成 JSON 再解析回来更保真
- 输出结构无法预知:探索性分析、开放对话,硬套 schema 只会逼模型编字段
判断口诀:输出给人或模型读,用自由文本;输出给代码读,用结构化。中间地带有混合模式——一个模型同时装"分析过程"自由文本字段和"结论"结构化字段:让评审 Agent 先在 reasoning 字段里自由分析,再在 verdict 字段里给结构化结论,下游只消费后者,兼得可读性与可解析性。
8、总结
回顾全文要点:Prompt 用 ChatPromptTemplate.from_messages 模板化管理,MessagesPlaceholder 注入历史;system 按"任务-约束-格式"三段组织;Few-shot 用 FewShotChatMessagePromptTemplate 解决规则说不清的格式问题,2~5 个为限;被代码消费的输出一律上 with_structured_output,字段描述当 Prompt 写、跨字段约束写进 description;解析失败用 include_raw=True 加错误反馈重试,语义越界用 field_validator 兜底。
对于新起的 Agent 项目,建议从第一个被代码消费的输出开始就用结构化——事后给存量 Prompt 补 schema 的成本,远高于一开始就做对。
留一个练习:做一个论文元数据抽取器。找 10 篇 arXiv 摘要,抽取标题、作者、年份、venue、关键词与方法分类:
- 作者用嵌套模型(姓名 + 机构),关键词是字符串数组
year加校验器限制 1950~2030,越界抛错- 实现带错误反馈的两轮重试
- 统计一次通过率,低于 80% 就回头修字段描述,而不是加更多重试
下一篇讲工具调用基础:@tool 装饰器、参数 schema 与 bind_tools。思想一以贯之——工具参数的 schema,本质上就是模型输出的另一种消费契约。


