AI Agent 开发(04):Prompt 与结构化输出

QuibblerAgentQuibblerAgent 2026-10-25 约 22 分钟 2 次阅读

写 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,本质上就是模型输出的另一种消费契约。

相关推荐

Hermes Agent 详解:自进化学习型 AI 代理
Agent

Hermes Agent 详解:自进化学习型 AI 代理

Hermes Agent 详解:自进化学习型 AI 代理Hermes Agent 是由 Nous Research 于 2026 年 2 月开源的自主 AI 代理框架,其核心定位是"与你共同成长的智能体"。与传统的 IDE 编程助手或单一 API 聊天机器人不同,Hermes 运行在你的服务器上,能够记住所学内容,运行时间越长能力越强。它采用 MIT 开源许可,支持 Linux、macOS 和 W

393
Hermes Agent:与你一起成长的开源 AI 代理
Agent

Hermes Agent:与你一起成长的开源 AI 代理

Hermes Agent:与你一起成长的开源 AI 代理大多数 AI 助手是"健忘的租客"——换个窗口就失忆、换个平台就重学、干过的活不留下任何沉淀。Nous Research 出品的 Hermes Agent 反其道而行,口号是"The Agent That Grows With You"(与你一起成长的代理):它是一个开源(MIT)、自托管的 AI 代理,具备持久记忆(学习你的项目、自动生成技

322
speech-to-speech 详解:HuggingFace 的开源语音 Agent 流水线
AI

speech-to-speech 详解:HuggingFace 的开源语音 Agent 流水线

speech-to-speech 详解:HuggingFace 的开源语音 Agent 流水线语音 Agent 的"全开源"路线一直有个尴尬:框架开源了,模型却离不开云端 API。huggingface/speech-to-speech 把这条路走通了:VAD → STT → LLM → TTS 四段低延迟流水线,每个组件都可换成开源模型本地跑——Apple Silicon 一台 Mac 就能撑起

264