前置
首先安装下环境
首先开启一个虚拟环境,然后安装一些依赖
pip install -U langchain
pip install langchain-deepseek
我使用的deepseek的key,就安装的是deepseek的依赖,然后跑一下文档中的例子:
python
from pathlib import Path
from dotenv import load_dotenv
from langchain.agents import create_agent
load_dotenv(dotenv_path=Path(__file__).resolve().parent.parent / ".ENV")
def get_weather(city: str) -> str:
"""Get weather for a given city."""
return f"It's always sunny in {city}!"
agent = create_agent(
model="deepseek:deepseek-chat",
tools=[get_weather],
system_prompt="You are a helpful assistant",
)
result = agent.invoke(
{"messages": [{"role": "user", "content": "What's the weather in San Francisco?"}]}
)
print(result["messages"][-1].content_blocks)
bash
#输出
[{'type': 'text', 'text': 'The weather in San Francisco is **always sunny**! 🌤️ A beautiful day in the City by the Bay!'}]
一、langchain 技术栈全景图
1、LangChain 在 AI 应用中的定位
LangChain 是目前最主流的大模型应用开发框架,它不负责训练大模型,而是帮助开发者更高效地构建 AI 应用。
刚开始做 AI 项目时,直接调用 OpenAI、Claude 或 DeepSeek 的 API 就能快速写出 Demo。但项目一旦变复杂,各种问题就会接踵而来:
- Prompt 越来越长,越来越难维护;
- 工具越来越多,调用逻辑越来越乱;
- 知识库和 RAG 不知道怎么接;
- 多步骤任务流程难以管理;
- Token 消耗、调用链路和线上问题难排查。
这些问题几乎是所有 AI 项目都会遇到的共性问题。
这就像早期 Web 开发可以直接写原生代码,但随着项目规模变大,大家最终都会选择 Spring、Django 这类框架。因为框架帮你解决了大量重复性的工程问题。
LangChain 在 AI 领域扮演的也是类似角色。它把 Prompt 管理、Tool Calling、RAG、工作流编排以及可观测性等能力进行了统一封装,让开发者不用重复造轮子,可以把更多精力放在业务逻辑上。
对于想从 Demo 走向生产环境的开发者来说,LangChain 已经成为 AI 应用开发的基础。
2 LangChain 生态组成
langchain之所以成为目前最主流的AI 应用开发基础框架,在于其非常完善的生态,接下来就详细的介绍下:
LangChain Core
这是langchian的基石,就是整个生态的统一接口规范,不做具体的实现,定义好大模型、 聊天模型、检索器、工具、消息等核心概念的标准协议 。让上层所有组件都能按同一个标准对接。
LangChain
这部分是工具与生态集成,基于Core的标准接口,做了大量的生态对接:上百家大模型的接入封装、几十种向量数据库的适配、各类第三方工具(搜索引擎、代码执行器、业务 API 等)的集成,同时还提供了很多封装好的常用链路(比如检索问答链、总结链)。
LangGraph
这部分负责状态流与Agent编排。将每一步逻辑定义成节点,把判断条件定义成边,轻松搭建出带状态、支持循环分支的工作流,不用自己手写一堆状态管理和循环判断的代码。
LangSmith
这块负责调试与评估,开发阶段它能完整记录每一次调用的全链路:输入输出、Token 消耗、每一步工具的执行结果、中间变量,排查问题一目了然;上线后可以用来做效果评估、版本对比,量化 Prompt 优化、策略调整带来的变化。
LangServe
这块是API部署工具,可以把你写好的链、Agent 快速打包成标准的 REST API,自带流式输出支持,不用你自己从零搭 FastAPI 服务、处理请求响应格式。几行代码就能把本地应用部署成可调用的线上服务,大幅缩短从开发到上线的路径。
3 从 Chain 到 LCEL
早期 LangChain 靠各类封装好的 Chain(如 LLMChain、RetrievalQA)快速出圈,搭 Demo 很省事,但落地生产时短板明显:一是黑盒感强,逻辑都封死在类里,想改中间逻辑、加自定义处理就得重写,灵活度极低;二是仅支持线性执行,分支、并行、循环这类复杂流程无法原生实现;三是生产能力缺失,流式输出、异步、批处理支持很差,常需要外层额外补逻辑;四是 Chain 种类繁杂,参数用法各不相同,学习和维护成本都高。
LCEL 的核心是把所有组件统一为标准 Runnable 接口,用管道符 | 像搭积木一样拼接链路。它成为官方主线,正是解决了旧 Chain 的核心问题:写法直观易读,增改组件灵活;原生自带流式、异步、批处理等生产能力;支持分支、并行、嵌套等复杂组合;和 LangSmith、LangServe 生态天然打通;同时大幅降低了学习心智负担。本质是从 "封装好的成品" 转向 "标准化积木",换来了生产环境下的可扩展性。
二、Runnable:langchian的统一执行模型
2.1 为啥需要Runnable
常规的Python方法,一个输入一个输出,然而在AI调用的时候,通常不是一个函数可以完成的,有很多组件串联起来的,比如:
ini
prompt = ChatPromptTemplate.from_template("请用一句话解释:{topic}")
model = ChatOpenAI()
parser = StrOutputParser()
这三行代码本质都像是函数,接受输入,产生输出,但是如果没有一个统一抽象,他们的调用方式就会差距大:
ini
prompt.format(topic="Runnable")
model.predict(...)
parser.parse(...)
每个方法都有自己的输入格式、输出格式,如果要串联起来就要手动处理每一步的适配,就需要额外写很多的胶水代码,而Runnable的出现就是为了解决胶水代码爆炸的问题,由一个统一的协议,不管是Prompt、Model、parser还是自定义函数,都尽量的表现得像同一种东西,这个就是Runnable。
在LangChain中,所有可执行组件,都被抽象成了Runnable,统一输入,统一输出,还有统一的执行方式,于是AI应用组件就可以轻松的组装成稳定、可组合、可调式、可扩展的工作流,而 Runnable 正是这个工作流的基础单位
前面的例子现在就可以这样写了
arduino
chain = prompt | model | parser
chain.invoke({"topic": "LangChain"})
这就是 LangChain Expression Language,也就是 LCEL 的核心写法。
这里的 | 不是普通管道符的装饰用法,而是在创建一个 RunnableSequence:前一个 Runnable 的输出,作为后一个 Runnable 的输入。官方源码文档里也说明,Runnable 可以通过 | 组合成序列,也可以通过 dict 组合成并行结构
2.2 Runnable 的设计思想
Runnable可以翻译为可运行对象,不是单纯的python函数,也不是普通类方法,而是Langchain定义的一套标准接口。
普通的方法是这样定义的:
python
def add_one(x):
return x + 1
add_one(1)
而Runnable是这样的:
arduino
runnable.invoke(1)
从使用者的角度来看,Runnable可以理解为:一个接受Input,产生Output的可执行组件。
这里的输入输出,并不是简单的字符串,而是一个泛型,也就是说输入输出的格式,由组件使用时自己声明。
Runnable也是有生命周期的,可以将Runnable的一次运行拆成五个阶段:
-
输入:用户传入Input,比如
arduinochain.invoke({"topic": "LangChain"})这个Input就会进入
Runnable -
执行,
Runnable开始执行自己的核心逻辑不同组件功能也不一样
PromptTemplate:格式化提示词 ChatModel:调用大模型 OutputParser:解析模型输出 Retriever:检索文档 Tool:执行外部动作 Chain:执行一组 Runnable比如:
arduinoprompt.invoke({"topic": "Runnable"}) #将变量塞入模板 model.invoke(prompt_value) #调用聊天模型 parser.invoke(ai_message) #将模型消息转成目标格式 -
输出
每个 Runnable 执行完都会返回 Output
cssPromptTemplate 输出 PromptValue ChatModel 输出 AIMessage StrOutputParser 输出 str Retriever 输出 list[Document] Tool 输出任意结果 Chain 输出最后一步的结果inichain = prompt | model | parser # 这个输出的就是 parser 的输出 -
异常处理
在批量调用时,
inirunnable.batch(inputs, return_exceptions=True)某个输入失败,会将异常作为结果返回,而不是将整个批处理中断
inichain = chain.with_retry()可以给Runnable 加重试能力
try / except去包括代码,也可以的 -
配置
Runnable 的调用可以带
config
所有的可执行组件都是Runnable,因为 LangChain 想要建立一个统一的 AI 应用执行模型 ,只要所有的组件都实现了Runnable,就可以做到统一调用、统一组合、统一配置、统一追踪。
下面看几个实现了Runnable的核心组件:
-
ChatModel
这个组件就是输出模型的回复,实现了
Runnable,就可以轻松的链式调用:inichain = prompt | modelChatModel 的输入可以是字符串、消息列表或 PromptValue,输出通常是 BaseMessage / AIMessage。LangChain 源码里的
BaseChatModel文档也列出了invoke、stream、batch等标准 Runnable 方法。 -
PromptTemplate
这个组件生成prompt,处于链路中的第一步,
-
OutputParser
这个组件是最终模型的结构化输出,是整个链路的最后一步,
inichain = prompt | model | parser源码里
BaseOutputParser继承自RunnableSerializable[LanguageModelOutput, T],意思是它接收语言模型输出,返回泛型结果T -
Retriever
这个组件输出相关的文档列表,Retriever 成为 Runnable 之后,就可以很自然地进入 RAG 流程。
源码里
BaseRetriever明确是RunnableSerializable[str, list[Document]],并且文档说明 Retriever 遵循标准 Runnable 接口,可以通过invoke、ainvoke、batch、abatch使用。 -
Tool
这个组件是调用工具的执行结果。
源码里
BaseTool继承自RunnableSerializable[str | dict[str, Any] | ToolCall, Any],说明 Tool 也是标准 Runnable。 -
Chain
这个就是一个链,也是一个Runnable ,而多个 Runnable 组合出来的更大的 Runnable。
官方源码里也说明,Runnable 可以通过
|组成RunnableSequence,也可以通过 dict 组成并行结构RunnableParallel
2.3 Runnable核心API
先看一个简单的实例
ini
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from langchain_core.output_parsers import StrOutputParser
prompt = ChatPromptTemplate.from_template("请用一句话解释:{topic}")
model = ChatOpenAI()
parser = StrOutputParser()
chain = prompt | model | parser
result = chain.invoke({"topic": "Runnable"})
print(result)
这里的chain就是一个最基础的链,后续的API都是基于他的。
invoke()
最基础的chain同步调用方式,输出这个链的最终结果。
他的返回值取决于链的最后一步,上面的案例,最终输出的就是parser的最终结果。
ainvoke()
ainvoke() 是 invoke() 的异步版本。
ini
result = await chain.ainvoke({"topic": "Runnable"})
print(result)
batch()
batch() 用来批量执行多个输入。
bash
results = chain.batch([
{"topic": "Runnable"},
{"topic": "LCEL"},
{"topic": "Agent"},
])
print(results)
#输出
[
"Runnable 是......",
"LCEL 是......",
"Agent 是......"
]
LangChain 源码里说明,batch() 的默认实现会用线程池并行调用 invoke(),适合 IO-bound 的 Runnable,比如模型请求、网络请求、检索请求等
默认情况下,只要某个输入报错,整个 batch() 可能抛异常。
ini
results = chain.batch(
inputs,
return_exceptions=True
)
#这样失败项会以异常对象的形式返回。
abatch()
abatch() 是 batch() 的异步版本。
它适合异步环境下批量处理。
源码里 abatch() 默认通过 asyncio.gather 并行运行多个 ainvoke(),并且同样支持 return_exceptions 和 max_concurrency。
stream()
stream() 用来流式输出。
lua
for chunk in chain.stream({"topic": "Runnable"}):
print(chunk, end="", flush=True)
它不会等完整结果生成完才返回,而是边生成边吐出 chunk。
LangChain 官方 Streaming 文档也强调,流式输出可以在完整结果准备好之前逐步展示内容,从而提升 LLM 应用的响应体验。
astream()
astream() 是 stream() 的异步版本。
lua
async for chunk in chain.astream({"topic": "Runnable"}):
print(chunk, end="", flush=True)
它适合异步服务中把模型输出实时推给前端。
stream_events()
stream_events() 用来获取"事件流"。
前面的 stream() 更关心最终内容:
模型生成了哪些 token?
而 stream_events() 更关心执行过程:
哪一步开始了?
哪一步结束了?
模型什么时候开始?
模型吐出了哪些 chunk?
parser 有没有执行?
retriever 有没有返回?
tool 有没有调用?
也就是说:
scss
stream() 看输出内容
stream_events() 看执行过程
LangChain 当前文档里,新的 agent / graph streaming 推荐使用 event streaming,特别是 version="v3" 的 typed projection API;而源码里也能看到,基础 Runnable 的同步 stream_events() 对 v1/v2 并不通用,v3 需要具体子类支持,比如 BaseChatModel 或 LangGraph CompiledGraph。实际项目里,如果你用的是普通 Runnable 链,常见选择是用异步的 astream_events(..., version="v2")。
2.4 Runnable的组合能力
Runnable 的组合能力,就是把多个"可运行单元"拼成一个更大的"可运行单元"。小 Runnable 能组合,大 Runnable 仍然是 Runnable,所以整条链还能继续 invoke()、batch()、stream()。
官方文档里也明确说,Runnable 主要组合原语是 RunnableSequence 和 RunnableParallel:前者顺序执行,后者并行执行;| 会创建 Sequence,dict 会被转换成 Parallel。
pipe()
pipe() 是 Runnable 的链式组合方法。
ini
chain = prompt.pipe(model).pipe(parser)
chain = prompt | model | parser
from langchain_core.runnables import RunnableSequence
chain = RunnableSequence(prompt, model, parser)
上面的三种写法完全一样,就是将当前 Runnable 和后面的 Runnable 串起来,形成 RunnableSequence。
RunnableParallel
RunnableParallel 是并行组合。就是将一个输入,同时交给多个 Runnable 执行,然后把结果合并成一个 dict。
ini
from langchain_core.runnables import RunnableParallel, RunnableLambda
parallel = RunnableParallel(
double=RunnableLambda(lambda x: x * 2),
triple=RunnableLambda(lambda x: x * 3),
)
parallel.invoke(10)
#输出
{
"double": 20,
"triple": 30
}
RunnableParallel 会并发调用多个 Runnable,并把相同输入提供给每个 Runnable;它可以直接实例化,也可以在 Sequence 中使用 dict 字面量创建。
RunnableBranch
根据输入判断条件,选择其中一条 Runnable 执行。
python
from langchain_core.runnables import RunnableBranch
branch = RunnableBranch(
(lambda x: x["type"] == "translate", translate_chain),
(lambda x: x["type"] == "summary", summary_chain),
default_chain,
)
branch.invoke({"type": "summary", "text": "..."})
#执行过程
是否 translate?否
是否 summary?是
执行 summary_chain
一个常见例子:简单问题直接回答,复杂问题走 RAG。
ini
branch = RunnableBranch(
(
lambda x: len(x["question"]) < 20,
simple_qa_chain,
),
rag_chain,
)
RunnableAssign
RunnableAssign 用来向 dict 数据中追加字段,就是输入一个 dict,计算一些新字段,再把新字段合并回原 dict。
python
from langchain_core.runnables import RunnablePassthrough
chain = RunnablePassthrough.assign(
question_length=lambda x: len(x["question"])
)
chain.invoke({"question": "什么是 Runnable?"})
#输出
{
"question": "什么是 Runnable?",
"question_length": 13
}
RunnablePassthrough
RunnablePassthrough 是"原样传递"。
python
from langchain_core.runnables import RunnablePassthrough
passthrough = RunnablePassthrough()
passthrough.invoke("hello")
#输出
"hello"
最常见场景:并行时保留用户原始问题。
python
from operator import itemgetter
from langchain_core.runnables import RunnablePassthrough
chain = {
"question": RunnablePassthrough(),
"context": retriever,
} | prompt | model | parser
#输入
"Runnable 是什么?"
#输出
{
"question": "Runnable 是什么?",
"context": [Document(...), Document(...)]
}
2.5 Runnable 的增强能力
增强就是不会改变Runnable的核心逻辑,返回一个新的Runnable
bind()
bind() 用来给 Runnable 绑定固定参数,这个参数以后每次调用都自动带上。
arduino
model_with_stop = model.bind(stop=["\n\n"])
chain = prompt | model_with_stop | parser
chain.invoke({"topic": "Runnable"})
后续再调用model的时候,就会自动带上stop=["\n\n"]
比较常见的用法就是绑定工具
ini
model_with_tools = model.bind_tools([search_tool, calculator_tool])
assign()
assign() 用来给 Runnable 的输出增加字段,它要求前一个 Runnable 的输出是 dict,然后在这个 dict 上追加新字段。
python
chain = some_chain.assign(
length=lambda x: len(x["text"])
)
#输入
{"text": "hello"}
#输出
{
"text": "hello",
"length": 5
}
使用场景一般在RAG中
csharp
from operator import itemgetter
chain = RunnablePassthrough.assign(
context=itemgetter("question") | retriever
)
#输入
{"question": "Runnable 是什么?"}
#输出
{
"question": "Runnable 是什么?",
"context": [Document(...), Document(...)]
}
map()
map() 用来把一个 Runnable 变成"可以处理列表输入"的 Runnable。
ini
from langchain_core.runnables import RunnableLambda
add_one = RunnableLambda(lambda x: x + 1)
mapped = add_one.map()
mapped.invoke([1, 2, 3])
#输出
[2, 3, 4]
map() 会返回一个新的 Runnable,它接收一组输入,逐个调用原 Runnable,然后返回输出列表
pick()
pick() 用来从 Runnable 的 dict 输出中选择字段。
makefile
chain = chain.pick("answer")
#如果原始输出是这样
{
"answer": "Runnable 是 LangChain 的统一执行接口",
"sources": [...],
"debug": {...}
}
#pick("answer") 后输出就是:
"Runnable 是 LangChain 的统一执行接口"
with_config()
with_config() 用来给 Runnable 绑定运行配置。
ini
chain = chain.with_config(
tags=["lesson-2", "runnable"],
metadata={"chapter": "2.5"},
run_name="runnable_enhanced_demo"
)
chain.invoke({"topic": "Runnable"})
#这些配置会跟着本次运行进入 tracing / callbacks / 子链。
with_retry()
with_retry() 用来给 Runnable 增加失败自动重试。
ini
chain = chain.with_retry(
stop_after_attempt=3
)
如果 chain 执行失败,最多尝试 3 次。
常见的配置:
ini
chain = chain.with_retry(
retry_if_exception_type=(TimeoutError, ConnectionError),
stop_after_attempt=3,
wait_exponential_jitter=True,
)
#含义
只在 TimeoutError 或 ConnectionError 时重试
最多尝试 3 次
重试间隔使用指数退避加随机抖动
with_fallbacks()
with_fallbacks() 用来给 Runnable 增加备用方案。
ini
chain = primary_chain.with_fallbacks([backup_chain])
#含义
先执行 primary_chain
如果失败,执行 backup_chain
如果还失败,再执行下一个 fallback
2.6 Runnable 最佳实践
会写chain = prompt | model | parser只是很基础的认知,真正重要的是:怎么拆、怎么组合、怎么复用、怎么调试、怎么优化性能。
什么时候拆 Runnable
当某一步逻辑有明确职责时,就应该拆成独立 Runnable。
比如一个 RAG 流程:
rust
用户问题
-> 查询改写
-> 检索文档
-> 格式化上下文
-> 构造 Prompt
-> 调用模型
-> 解析输出
不要写成一个巨大函数:
python
def rag(question):
# 改写问题
# 检索文档
# 拼 prompt
# 调模型
# 解析结果
# 返回答案
更好的方式是拆成多个 Runnable:
ini
query_rewrite_chain = rewrite_prompt | model | parser
retrieval_chain = query_rewrite_chain | retriever
answer_chain = prompt | model | parser
拆 Runnable 的判断标准:
这一步能不能单独测试?
这一步能不能被别的链复用?
这一步出错时,我是否希望单独定位?
这一步是否有独立输入和输出?
如果答案是"是",就适合拆出来。
什么时候组合 Runnable
当多个 Runnable 共同完成一个业务流程时,就应该组合。
组合 Runnable 的判断标准:
这些步骤是不是天然有先后顺序?
上一步输出是不是下一步输入?
调用方是否只关心最终结果?
这组流程是否经常一起出现?
如果是,就组合成一个新的 chain。
组合后的 chain 本身仍然是 Runnable,所以可以继续组合:
ini
answer_chain = prompt | model | parser
stable_answer_chain = answer_chain.with_retry()
api_chain = stable_answer_chain.with_config(run_name="answer_api")
这就是 Runnable 最舒服的地方:
小组件组合成大组件,大组件仍然能当小组件使用。
如何保证可维护性
第一,给关键 Runnable 命名。
ini
retriever = retriever.with_config(run_name="knowledge_retriever")
answer_chain = answer_chain.with_config(run_name="answer_chain")
这样在 tracing 或事件流里能看清楚每一步。
第二,保持输入输出清晰。
不要让一个链的输入格式忽左忽右。
推荐:
json
{
"question": "...",
"context": "...",
"history": [...]
}
不推荐一会儿传字符串,一会儿传 dict,一会儿传复杂对象。
第三,中间状态尽量用 dict 表达。
比如:
ini
chain = RunnablePassthrough.assign(
context=itemgetter("question") | retriever
) | prompt | model | parser
这样数据流很清楚:
先有 question
再追加 context
然后进入 prompt
第四,不要把业务逻辑藏在匿名 lambda 里太多。
少量可以:
less
lambda x: x["question"]
复杂逻辑应该拆成函数:
python
def format_docs(docs):
return "\n\n".join(doc.page_content for doc in docs)
然后再包成 Runnable:
ini
format_docs_runnable = RunnableLambda(format_docs)
如何提高复用性
复用性的核心是:不要让 Runnable 绑定太多具体业务上下文。
比如这个不太好复用:
ini
customer_service_chain = prompt | model | parser
如果 prompt 里写死了某个产品、某个用户、某个场景,那它只能服务一个地方。
更好的方式是把变量暴露出来:
ini
prompt = ChatPromptTemplate.from_template("""
你是一个{role}。
请用{tone}的语气回答用户问题:
{question}
""")
这样同一个 Runnable 可以用于多个场景:
arduino
chain.invoke({
"role": "客服助手",
"tone": "友好",
"question": "怎么退款?"
})
提高复用性的几个方法:
Prompt 参数化,不要写死变量。
Parser 独立出来,不要混在模型调用后面。
Retriever 独立出来,方便替换知识库。
Model 独立出来,方便换模型或 fallback。
格式化逻辑独立出来,方便复用和测试。
避免一个 Runnable 做太多事情
一个 Runnable 最好只做一类事情。
不推荐:
javascript
一个 Runnable 同时负责:
清洗输入
检索文档
拼 prompt
调用模型
解析 JSON
写数据库
返回 API 响应
这会导致几个问题:
难测试
难复用
难调试
难替换
难定位性能瓶颈
更好的方式:
rust
clean_input
-> retrieve_docs
-> format_context
-> prompt
-> model
-> parser
-> save_result
每一步都很小,但组合起来很强。
保持单一职责
单一职责可以这样判断:
这个 Runnable 的名字能不能用一个动词说清楚?
好名字:
rewrite_query
retrieve_docs
format_context
generate_answer
parse_json
rank_documents
不好的名字:
arduino
do_everything
process
main_chain
handle_user_request
如果一个 Runnable 的名字只能叫 process,通常说明它做太多了。
一个好的 Runnable 应该像这样:
ini
format_docs = RunnableLambda(lambda docs: "\n\n".join(
doc.page_content for doc in docs
))
职责非常清楚:
输入 docs
输出格式化后的 context 字符串
最佳实践总结
python
拆 Runnable:
当某一步有独立职责、可测试、可复用时拆。
组合 Runnable:
当多个步骤构成稳定业务流程时组合。
维护性:
命名清楚,输入输出稳定,中间状态用 dict,复杂逻辑不要藏在 lambda 里。
复用性:
Prompt 参数化,模型独立,Parser 独立,Retriever 独立。
单一职责:
一个 Runnable 只做一类事情。
性能:
批量任务用 batch。
异步服务用 ainvoke / abatch。
独立分支用 Parallel。
用户界面用 Streaming。
成本敏感时减少 token。
重复计算用缓存。
2.7 实战:构建一个 Runnable Workflow
本章节打算做一个只能文章助手,用户输入一篇文章或一段文本,系统自动判断任务类型,并完成摘要、关键词提取、分类、风险提示或改写等处理,最后以流式方式输出结果。
这个智能助手,当用户输入一段文字时,需要做这些事情
理解用户输入
判断用户想做什么
根据任务类型选择不同处理流程
并行生成多个辅助结果
调用模型生成最终结果
失败时自动重试
主模型不可用时切换备用模型
最终支持流式输出
接下来分为这么几步
基础链路
首先这一节会写一个最基础的chain,按照如下流程:
css
用户输入 article
↓
PromptTemplate 构造摘要提示词
↓
ChatModel 生成摘要
↓
StrOutputParser 解析成字符串
↓
返回 summary
看下代码:
python
import os
from pathlib import Path
from dotenv import load_dotenv
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
ENV_PATH = Path(__file__).resolve().parent.parent / ".ENV"
load_dotenv(dotenv_path=ENV_PATH)
def _required_env(name: str) -> str:
value = os.getenv(name)
if not value:
raise ValueError(f"Missing required env variable: {name}")
return value
def create_chat_model(
*,
model: str | None = None,
base_url: str | None = None,
api_key: str | None = None,
temperature: float = 0.2,
) -> ChatOpenAI:
"""Create an OpenAI-compatible chat model from .ENV config."""
timeout = int(os.getenv("LLM_TIMEOUT", "60"))
return ChatOpenAI(
model=model or _required_env("LLM_MODEL_ID"),
base_url=base_url or _required_env("LLM_BASE_URL"),
api_key=api_key or _required_env("LLM_API_KEY"),
temperature=temperature,
timeout=timeout,
)
summary_prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"你是一个专业的中文内容编辑,擅长把长文章总结成清晰、准确、易读的摘要。",
),
(
"human",
"""请阅读下面的文章,并生成摘要。
要求:
1. 用中文回答。
2. 先给出 3-5 条核心要点。
3. 最后给出一句话总结。
4. 只基于文章内容总结,不要编造文章中没有的信息。
文章:
{article}
""",
),
]
)
model = create_chat_model()
parser = StrOutputParser()
# Core Runnable workflow:
# article -> PromptTemplate -> ChatModel -> StrOutputParser -> summary
summary_chain = summary_prompt | model | parser
def summarize(article: str) -> str:
"""Run summary_chain with a plain article string."""
return summary_chain.invoke({"article": article})
if __name__ == "__main__":
article_path = Path(__file__).resolve().parent.parent / "data" / "zhufu.txt"
demo_article = article_path.read_text(encoding="utf-8")
print(summarize(demo_article))
#输出
### 一句话总结
鲁迅通过祥林嫂的悲剧,深刻揭示了封建礼教、迷信思想和社会冷漠如何一步步将一个勤劳善良的农村妇女推向死亡,同时批判了知识分子的软弱与旁观。
核心代码就是这行summary_chain = summary_prompt | model | parser
summary_prompt是手写的一个提示词,告知agent身份、角色,model是调用ChatOpenAI来创建的,通过load_dotenv加载.env文件中设置的base和key,StrOutputParser是最基础的输出解析器。
最后summary_chain.invoke({"article": article}),article是加载本地的一个txt文本,最后输出总结。
增加并行执行
现在文章只有一个摘要总结,现在需要文章的关键字、分类以及文章的情绪倾向,就需要额外再创建三个chian
python
keywords_prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"你是一个中文文本分析助手,擅长从文章中提取关键词。",
),
(
"human",
"""请从下面文章中提取 5-8 个关键词。
要求:
1. 只返回 JSON 数组。
2. 不要返回 Markdown。
3. 不要添加解释。
示例:
["关键词1", "关键词2", "关键词3"]
文章:
{article}
""",
),
]
)
#提取关键字的chain
keywords_chain = keywords_prompt | model | json_parser
category_prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"你是一个中文文章分类助手,擅长判断文章所属类别。",
),
(
"human",
"""请判断下面文章最适合的一个类别。
要求:
1. 只返回一个简短类别名称。
2. 不要添加解释。
3. 类别可以是:文学、科技、商业、教育、历史、社会、人物、生活、其他。
文章:
{article}
""",
),
]
)
#判断文章所属类别的chain
category_chain = category_prompt | model | parser
sentiment_prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"你是一个中文情绪分析助手,擅长判断文章整体情绪倾向。",
),
(
"human",
"""请判断下面文章的整体情绪倾向。
要求:
1. 只返回一个词。
2. 可选值只能是:积极、消极、中性、复杂。
3. 不要添加解释。
文章:
{article}
""",
),
]
)
#分析文章情绪倾向的chian
sentiment_chain = sentiment_prompt | model | parser
然后就可以使用前面介绍过的RunnableParallel,并行创建这四个chain
ini
analysis_chain = RunnableParallel(
summary=summary_chain,
keywords=keywords_chain,
category=category_chain,
sentiment=sentiment_chain,
)
本节输出:
css
{'summary': '### 核心要点\n\n1. **故事背景与叙述者**:故事发生在旧历年底的鲁镇,叙述者"我"回到故乡,暂住在鲁四老爷家,感受到浓厚的"祝福"氛围,但内心不安,决定离开。\n\n2. **祥林嫂的悲惨遭遇**:祥林嫂是一个勤劳的寡妇,先后经历丈夫去世、被婆婆强迫改嫁、第二任丈夫死于伤寒、儿子阿毛被狼叼走等打击,最终沦为乞丐,在祝福之夜冻饿而死。\n\n3. **社会冷漠与封建礼教**:鲁镇的人们对祥林嫂的苦难从同情转为厌烦,鲁四老爷视她为"谬种",禁止她参与祭祀,认为她"败坏风俗";柳妈以"阴司锯开"的迷信恐吓她,导致她捐门槛赎罪却仍被排斥。\n\n4. **祥林嫂的精神崩溃**:在反复讲述儿子被狼吃的故事遭人厌弃后,祥林嫂捐门槛仍不被接纳,彻底失去希望,变得麻木、胆怯,最终被赶出家门,沦为乞丐。\n\n5. **叙述者的反思**:叙述者面对祥林嫂关于"魂灵有无"的追问,含糊其辞,事后感到不安,最终在祝福的爆竹声中,以"懒散且舒适"的心态结束故事,暗示对现实的无奈与逃避。\n\n### 一句话总结\n\n鲁迅通过祥林嫂的悲剧,深刻揭露了封建礼教、迷信思想和社会冷漠对底层妇女的摧残,以及知识分子在残酷现实面前的无力与自欺。', 'keywords': ['祥林嫂', '鲁镇', '祝福', '四叔', '阿毛', '捐门槛', '魂灵', '封建礼教'], 'category': '文学', 'sentiment': '消极'}
增加条件分支
有了这四个chain,就可以根据用户的输入,来判断用户的任务类型,然后再去执行不同的chian,这就需要用到前面介绍的RunnableBranch,根据输入判断条件,选择其中一条 Runnable 执行。
具体的流程是这样的,首先用户输入一段文本,有一个任务识别的chain作为路由分发,根据任务的类型分别调用不同的chain。
python
task_identify_prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"你是一个任务路由助手,根据用户指令选择最合适的文章处理链。",
),
(
"human",
"""请根据用户指令判断任务类型。
只能返回下面 5 个值之一:
summarize
keywords
category
sentiment
analysis
判断规则:
- 用户想总结、概括、摘要文章时,返回 summarize。
- 用户想提取关键词、标签、主题词时,返回 keywords。
- 用户想判断文章类型、类别、题材时,返回 category。
- 用户想分析情绪、态度、倾向时,返回 sentiment。
- 用户想要完整分析,或者无法判断时,返回 analysis。
用户指令:
{instruction}
文章:
{article}
""",
),
]
)
#任务分发的chain'
task_identify_chain = task_identify_prompt | model | parser | RunnableLambda(normalize_task)
def normalize_task(text: str) -> str:
"""Normalize model output into one of the supported route names."""
task = text.strip().lower()
allowed_tasks = ("summarize", "keywords", "category", "sentiment", "analysis")
for allowed_task in allowed_tasks:
if allowed_task in task:
return allowed_task
return "analysis"
这个chain就是一个判断任务类型的,根据用户的输入会返回一个类别,"summarize", "keywords", "category", "sentiment", "analysis"就是这里面的其中一个,如果没有就默认返回"analysis"
python
def run_article_workflow(article: str, instruction: str) -> dict:
"""Identify the task and route to the matching RunnableBranch."""
return article_workflow.invoke(
{
"article": article,
"instruction": instruction,
}
)
article_workflow = routed_input_chain | RunnableParallel(
task=lambda x: x["task"],
result=branch_chain,
)
routed_input_chain = RunnableParallel(
article=lambda x: x["article"],
task=task_identify_chain,
)
branch_chain = RunnableBranch(
(lambda x: x["task"] == "summarize", article_only | summary_chain),
(lambda x: x["task"] == "keywords", article_only | keywords_chain),
(lambda x: x["task"] == "category", article_only | category_chain),
(lambda x: x["task"] == "sentiment", article_only | sentiment_chain),
article_only | analysis_chain,
)
article_only = RunnableLambda(article_input)
#调用方
demo_instruction = "请提取这篇文章的关键词"
print(run_article_workflow(demo_article, demo_instruction))
#输出
{'task': 'keywords', 'result': ['祥林嫂', '鲁镇', '祝福', '四叔', '阿毛', '捐门槛', '魂灵', '封建礼教']}
run_article_workflow方法调用时,传递了原本和用户的问题,然后执行article_workflow将这两个参数透传下去,article_workflow会先去执行routed_input_chain,这个chain就是最终的输出结果,将入参转化成{task:'',result:''},routed_input_chain会将入参转换成{article:'',task:''},branch_chain这个chain就是chain的转发。
增加重试机制
接着给关键链路加 with_retry(),比如网络超时、模型返回的格式不稳定,可以多试几次
ini
RETRYABLE_EXCEPTIONS = (
TimeoutError,
ConnectionError,
APIConnectionError,
APITimeoutError,
RateLimitError,
InternalServerError,
OutputParserException,
)
def with_temporary_retry(runnable):
"""Retry temporary model/network/parser failures, then re-raise."""
return runnable.with_retry(
retry_if_exception_type=RETRYABLE_EXCEPTIONS,
stop_after_attempt=3,
wait_exponential_jitter=True,
)
#关键的chain都被包裹着
summary_chain = with_temporary_retry(summary_prompt | model | parser)
keywords_chain = with_temporary_retry(keywords_prompt | model | json_parser)
category_chain = with_temporary_retry(category_prompt | model | parser)
sentiment_chain = with_temporary_retry(sentiment_prompt | model | parser)
task_identify_chain = with_temporary_retry(
task_identify_prompt | model | parser | RunnableLambda(normalize_task)
)
final_chain = with_temporary_retry(article_workflow)
增加Fallback
当 with_retry 继续失败后,接着引入 with_fallbacks(),选用备用模型继续处理。
scss
def create_backup_chat_model() -> ChatOpenAI:
"""Create a backup model for fallback; defaults to primary config if unset."""
return create_chat_model(
model=os.getenv("BACKUP_LLM_MODEL_ID")
or os.getenv("LLM_BACKUP_MODEL_ID")
or _required_env("LLM_MODEL_ID"),
base_url=os.getenv("BACKUP_LLM_BASE_URL")
or os.getenv("LLM_BACKUP_BASE_URL")
or _required_env("LLM_BASE_URL"),
api_key=os.getenv("BACKUP_LLM_API_KEY")
or os.getenv("LLM_BACKUP_API_KEY")
or _required_env("LLM_API_KEY"),
timeout=int(
os.getenv("BACKUP_LLM_TIMEOUT")
or os.getenv("LLM_BACKUP_TIMEOUT")
or os.getenv("LLM_TIMEOUT", "60")
),
)
def with_retry_then_fallback(primary_runnable, backup_runnable):
"""Retry the primary runnable first, then switch to the backup runnable."""
primary_with_retry = with_temporary_retry(primary_runnable)
backup_with_retry = with_temporary_retry(backup_runnable)
return primary_with_retry.with_fallbacks(
[backup_with_retry],
exceptions_to_handle=RETRYABLE_EXCEPTIONS,
)
summary_chain = with_retry_then_fallback(
summary_prompt | primary_model | parser,
summary_prompt | backup_model | parser,
)
keywords_chain = with_retry_then_fallback(
keywords_prompt | primary_model | json_parser,
keywords_prompt | backup_model | json_parser,
)
category_chain = with_retry_then_fallback(
category_prompt | primary_model | parser,
category_prompt | backup_model | parser,
)
sentiment_chain = with_retry_then_fallback(
sentiment_prompt | primary_model | parser,
sentiment_prompt | backup_model | parser,
)
核心方法就是with_retry_then_fallback,主链先 retry 3 次,如果失败了,就使用备用链 retry 3 次。
增加Streaming
最后引入 stream()。
最终结果生成时,不再等完整答案生成完,而是边生成边输出。
python
def final_answer_input(workflow_output: dict) -> dict:
"""Format routed workflow output for the final answer prompt."""
return {
"task": workflow_output["task"],
"result": json.dumps(
workflow_output["result"],
ensure_ascii=False,
indent=2,
),
}
final_answer_prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"你是一个中文文章处理助手,负责把工作流结果整理成清晰、自然、适合直接展示给用户的回答。",
),
(
"human",
"""请根据下面的工作流结果,生成最终回答。
要求:
1. 用中文回答。
2. 不要提及内部 chain、Runnable、workflow 等实现细节。
3. 如果 result 是列表,整理成简洁列表。
4. 如果 result 是对象,按字段含义组织成易读内容。
5. 如果 task 是 analysis,输出摘要、关键词、分类、情绪四部分。
任务类型:
{task}
工作流结果:
{result}
""",
),
]
)
final_answer_chain = with_retry_then_fallback(
final_answer_prompt | primary_model | parser,
final_answer_prompt | backup_model | parser,
)
streaming_workflow_chain = final_chain | RunnableLambda(final_answer_input) | final_answer_chain
def stream_article_workflow(article: str, instruction: str):
"""Stream the final user-facing answer."""
return streaming_workflow_chain.stream(
{
"article": article,
"instruction": instruction,
}
)
就是在之前的基础上,在继续调用chian,将结果转换成流失输出,最终的执行流程如下:
scss
用户输入
↓
输入预处理
↓
任务识别
↓
RunnableBranch 路由
├─ 摘要流程
├─ 改写流程
├─ 要点提取流程
└─ 通用分析流程
↓
RunnableParallel 并行补充分析
├─ 摘要
├─ 关键词
├─ 分类
└─ 情绪
↓
最终 Prompt 整合结果
↓
主模型生成
↓
Parser 解析
↓
with_retry 保证临时失败可恢复
↓
with_fallbacks 保证主模型失败时可降级
↓
stream() 流式输出
三、Prompt API
3.1 为什么需要 Prompt API
·Prompt实际上就是提示词,投喂给大模型的文本字符串,大模型根据输入的提示词来做输出,Langchain提供了大量的API来操作prompt,而不是单纯的将prompt当做字符串去操作,目的就是为了将提示词工程化,能够更好的适应一些复杂的场景:
-
适配不同的大模型
不同厂商的大模型输入格式是不一样的, OpenAI:
[{"role":"system"},{"role":"user"}]; 阿里通义:system:xxx\nuser:xxx,如果使用单纯的字符串就需要写大量的代码去适配 -
复杂的工程能力手动写字符串无法实现
涉及到根据输入动态增减提示词片段、自动追加输出格式约束、多段提示词分层组合,这些场景手写字符串是无法实现的
-
Token管控,上下文窗口安全
大模型有最大输入长度限制的,裸字符串无法自动计算token,自动截断冗余内容
-
标准化对接上下游组件
根据前面的最基础的chain学习,我们知道
Prompt它实际上也是一个Runnable,它的输出是要喂给model的。
所以Langchain提供了大量的API,让prompt有了工程化的能力,可以适配各种复杂场景。
接下来详细的介绍下这些API
3.2 PromptTemplate
PromptTemplate 是 LangChain 中最基础的 Prompt 模板,适用一些普通文本模型或简单字符串 Prompt。
ini
from langchain_core.prompts import PromptTemplate
prompt = PromptTemplate.from_template("请总结下面文章:{article}")
这里的prompt,是一个符合Runnable格式的模板对象,接受一个article参数,当它调用invoke时,需要传入这个参数:
ini
result = prompt.invoke({"article": "测试文本"})
此时result是一个 PromptValue
ini
text = prompt.format(article="这里是一篇文章")
这个text就是一个标准的字符串了
在 LangChain 工作流中,更推荐理解和使用 invoke(),因为它能自然接入stream、with_config和LCEL。
当涉及到一些简单的场景,PromptTemplate 可以把普通字符串 Prompt 变成可复用、可组合、可执行的模板组件
3.3 ChatPromptTemplate
PromptTemplate 适合一些简单的文本,ChatPromptTemplate就更加适合一些聊天场景。ChatModel他不只是接受一个字符串,更加适合接受一个消息结构的列表,包含SystemMessage、HumanMessage和AIMessage
比如下面这个代码:
css
from langchain_core.prompts import ChatPromptTemplate
prompt = ChatPromptTemplate.from_messages([ ("system", "你是一个专业的中文写作助手。"), ("human", "请总结下面文章:{article}")])
这个结构就更加适合真实的对话,有system定义角色、边界和规则;human代表用户的输入;ai代表模型的实例或者历史回复。
ChatPromptTemplate和PromptTemplate最大的区别就是,前者将 Prompt 从"字符串模板"升级成了"消息结构模板"
3.4 MessagesPlaceholder
MessagesPlaceholder 用来在 ChatPromptTemplate 中动态插入一组消息,常常用于多轮对话场景。
普通的ChatPromptTemplate他的结构是固定的,比如前面章节的实例:
ini
prompt = ChatPromptTemplate.from_messages([
("system", "你是一个智能助手。"),
("human", "{question}")
])
但真实聊天里,通常还有历史记录:
用户:什么是 Runnable?
助手:Runnable 是 LangChain 的统一执行接口。
用户:那它和普通函数有什么区别?
这些历史消息不是写死在 Prompt 里的,而是运行时传入。
这时就需要 MessagesPlaceholder:
css
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
prompt = ChatPromptTemplate.from_messages([ ("system", "你是一个智能助手。"), MessagesPlaceholder("history"), ("human", "{question}")])
调用时:
arduino
prompt.invoke({
"history": [
("human", "什么是 Runnable?"),
("ai", "Runnable 是 LangChain 的统一执行接口。"),
],
"question": "那它和普通函数有什么区别?"
})
最终消息结构会变成:
perl
system:你是一个智能助手。
human:什么是 Runnable?
ai:Runnable 是 LangChain 的统一执行接口。
human:那它和普通函数有什么区别?
如果历史消息为空,还可以这样设置,避免报错
ini
MessagesPlaceholder("history", optional=True)
同样也可以设置保留最近的几条历史记录
ini
MessagesPlaceholder("history", n_messages=4)
3.5 FewShot Prompt
FewShot Prompt 指的是:在正式问题之前,先给模型几个示例,让模型模仿示例的格式、风格和推理方式。 也可以理解为,不知告诉模型要怎么做,而是直接给模型看正确答案。
看下这个案例:
ini
from langchain_core.prompts import FewShotPromptTemplate, PromptTemplate
examples = [
{
"article": "这篇文章介绍了大语言模型的发展。",
"category": "科技",
},
{
"article": "这篇文章讲述了鲁迅的文学创作。",
"category": "文学",
},
]
example_prompt = PromptTemplate.from_template(
"输入:{article}\n输出:{category}"
)
prompt = FewShotPromptTemplate(
examples=examples,
example_prompt=example_prompt,
prefix="请判断文章类别。参考下面示例:",
suffix="输入:{article}\n输出:",
input_variables=["article"],
)
prompt.invoke({
"article": "这篇文章分析了一家公司的商业模式。"
})
最终输出的prompt是这样
请判断文章类别。参考下面示例:
输入:这篇文章介绍了大语言模型的发展。
输出:科技
输入:这篇文章讲述了鲁迅的文学创作。
输出:文学
输入:这篇文章分析了一家公司的商业模式。
输出:
这里使用 FewShot Prompt的好处主要在于约束输出格式、稳定模型的行为
如果是文本Prompt可以使用前面的FewShotPromptTemplate,如果是对话类的, 更推荐使用 FewShotChatMessagePromptTemplate
ini
from langchain_core.prompts import (
ChatPromptTemplate,
FewShotChatMessagePromptTemplate,
)
examples = [
{"input": "什么是 Runnable?", "output": "Runnable 是 LangChain 的统一执行接口。"},
{"input": "什么是 PromptTemplate?", "output": "PromptTemplate 是用于构造 Prompt 的模板。"},
]
example_prompt = ChatPromptTemplate.from_messages([
("human", "{input}"),
("ai", "{output}"),
])
few_shot_prompt = FewShotChatMessagePromptTemplate(
examples=examples,
example_prompt=example_prompt,
)
prompt = ChatPromptTemplate.from_messages([
("system", "你是一个 LangChain 教学助手。"),
few_shot_prompt,
("human", "{input}"),
])
最终消息结构类似:
css
system:你是一个 LangChain 教学助手。
human:什么是 Runnable?
ai:Runnable 是 LangChain 的统一执行接口。
human:什么是 PromptTemplate?
ai:PromptTemplate 是用于构造 Prompt 的模板。
human:{input}
这种方式更适合 ChatModel,因为它保留了对话结构。
3.6 Example Selector
FewShot Prompt 是通过示例教模型做事,比单纯写规则可以得到大模型更加直观、稳定的输出。但是案例都会进入prompt,一起投喂给大模型,一旦案例过长,就会造成token额外的消耗,而且无关的案例还会干扰模型。
langchain还提供了一个 Example Selector ,当面对大量的案例时,会根据输入,动态的选择几个最合适的案例来塞入prompt,具体流程是这样的
markdown
用户问题
│
▼
Example Selector
│
挑选最相关 Example
│
▼
FewShotPromptTemplate
│
拼接 Prompt
│
▼
LLM
常见的选择器主要有下面几种:
-
LengthBasedExampleSelector
这个是最简单的一个选择器,根据长度来控制prompt。比如设置prompt长度为4000token,他就会一直塞案例到prompt中,知道塞满为止。
看下这个案例
inifrom langchain_core.example_selectors import LengthBasedExampleSelector from langchain_core.prompts import PromptTemplate, FewShotPromptTemplate examples = [ {"input": "1+1", "output": "2"}, {"input": "2+3", "output": "5"}, {"input": "123 + 456", "output": "579"}, {"input": "请把"我喜欢学习 LangChain"翻译成英文", "output": "I like learning LangChain."}, ] example_prompt = PromptTemplate( input_variables=["input", "output"], template="输入: {input}\n输出: {output}" ) selector = LengthBasedExampleSelector( examples=examples, example_prompt=example_prompt, max_length=30 ) print(selector.select_examples({"input": "3+4"}))这个案例库比较小,他只会选择一些比较短的案例,因为这样比较节省token,如果案例库够长,他只会塞入比较短的30个案例
-
SemanticSimilarityExampleSelector
这是企业项目最常见选择器,实际上就是和RAG的流程一模一样,先转换成向量,然后在做语义相似度匹配,找到和用户输入最相似的案例。
看下这个案例
inifrom langchain_core.example_selectors import SemanticSimilarityExampleSelector from langchain_core.prompts import PromptTemplate, FewShotPromptTemplate from langchain_openai import OpenAIEmbeddings from langchain_community.vectorstores import FAISS examples = [ {"question": "怎么重置密码?", "answer": "点击登录页的忘记密码。"}, {"question": "怎么修改头像?", "answer": "进入个人中心,点击头像上传新图片。"}, {"question": "怎么删除账号?", "answer": "进入设置页面,找到账号注销入口。"}, {"question": "怎么绑定手机号?", "answer": "进入安全设置,选择绑定手机号。"}, ] example_prompt = PromptTemplate( input_variables=["question", "answer"], template="问题: {question}\n回答: {answer}" ) selector = SemanticSimilarityExampleSelector.from_examples( examples=examples, embeddings=OpenAIEmbeddings(), vectorstore_cls=FAISS, k=2 ) selected = selector.select_examples({"question": "如何更换我的头像?"}) print(selected)这里就会根据用户的输入,去案例库中匹配语义最相似的案例去投喂给大模型
-
MaxMarginalRelevanceExampleSelector
这个是兼容多样性和相关性的,尽量在案例库中挑选一些相关又不重复的案例。
inifrom langchain_core.example_selectors import MaxMarginalRelevanceExampleSelector from langchain_core.prompts import PromptTemplate from langchain_openai import OpenAIEmbeddings from langchain_community.vectorstores import FAISS examples = [ {"topic": "头像", "question": "怎么修改头像?", "answer": "进入个人中心更换头像。"}, {"topic": "头像", "question": "怎么上传新头像?", "answer": "点击头像区域并选择图片。"}, {"topic": "昵称", "question": "怎么修改昵称?", "answer": "进入资料编辑页修改昵称。"}, {"topic": "手机号", "question": "怎么绑定手机号?", "answer": "进入安全设置绑定手机号。"}, {"topic": "密码", "question": "怎么重置密码?", "answer": "点击忘记密码进行重置。"}, ] example_prompt = PromptTemplate( input_variables=["topic", "question", "answer"], template="主题: {topic}\n问题: {question}\n回答: {answer}" ) selector = MaxMarginalRelevanceExampleSelector.from_examples( examples=examples, embeddings=OpenAIEmbeddings(), vectorstore_cls=FAISS, k=2, fetch_k=5 ) selected = selector.select_examples({"question": "我想修改个人资料里的头像和昵称"}) print(selected)
3.7 Partial Prompt
Partial Prompt 指的是:提前固定 Prompt 中的一部分变量。 比如在传递prompt时,如果在prompt中有很多变量,就可以通过 partial 来固定某一些变量,后续传参中就可以不传那些被固定的变量了,有点类似于函数式编程中的函数柯里化,起到一个节约传参的目的。
ini
base_prompt = PromptTemplate.from_template("你是{role},针对{topic}回答:{question}")
# 第一步绑定role,生成半成品1
step1 = base_prompt.partial(role="资深后端工程师")
# 第二步再绑定topic,生成半成品2,只剩question需要传入
step2 = step1.partial(topic="Python异步")
# 最终只传question
print(step2.format(question="asyncio原理"))
3.8 Pipeline Prompt
一个复杂的prompt会由好几个部分组成,比如角色说明、任务说明、输出格式等,如果全部写在一个大的字符串中,会很难维护,langchian推出了 Pipeline Prompt 这个api,可以将多个prompt片段组合成一个prompt。
python
from langchain_core.prompts import PromptTemplate
role_prompt = PromptTemplate.from_template(
"你是一个专业的{domain}助手。"
)
task_prompt = PromptTemplate.from_template(
"你的任务是:{task}"
)
format_prompt = PromptTemplate.from_template(
"输出格式:{format_instruction}"
)
final_prompt = PromptTemplate.from_template("""
{role}
{task}
{format_instruction}
用户输入:
{input}
""")
data = {
"domain": "文章分析",
"task": "总结文章并提取关键词",
"format_instruction": "请输出 JSON",
"input": "这里是文章内容..."
}
prompt_input = {
"role": role_prompt.invoke(data).to_string(),
"task": task_prompt.invoke(data).to_string(),
"format_instruction": format_prompt.invoke(data).to_string(),
"input": data["input"],
}
result = final_prompt.invoke(prompt_input)
3.9 Prompt 最佳实践
一个优秀的prompt,需要让模型清楚的知道他的角色、目标、参考对象、需要遵守的规则,还有就是输入和输出的格式,而不是一大坨自然语言。
可以根据下面的规范来编写prompt
结构化
推荐结构:
角色
任务
上下文
约束
输出格式
示例
用户输入
比如文章摘要的prompt可以这么写:
ini
summary_prompt = ChatPromptTemplate.from_messages([
(
"system",
"你是一个专业的中文内容编辑,擅长把长文章总结成清晰、准确、易读的摘要。"
),
(
"human",
"""
任务:
请阅读下面文章,并生成摘要。
要求:
1. 用中文回答。
2. 提取 3-5 条核心要点。
3. 最后给出一句话总结。
4. 只基于文章内容,不要编造信息。
输出格式:
核心要点:
- ...
- ...
一句话总结:
...
文章:
{article}
"""
)
])
不推荐:
你是一个助手,请帮我分析这篇文章,要求准确、专业、简洁,还要输出关键词......
推荐拆成:
css
角色:
你是一个专业的文章分析助手。
任务:
请分析用户提供的文章。
要求:
1. 只基于文章内容回答。
2. 不要编造信息。
3. 用中文输出。
输出格式:
{
"summary": "...",
"keywords": ["...", "..."],
"category": "...",
"sentiment": "..."
}
输入:
{article}
明确输出格式
javascript
只返回 JSON
只返回列表
只返回一个类别
不要添加解释
不要返回 Markdown
比如关键词提取的prompt
ini
keywords_prompt = ChatPromptTemplate.from_messages([
("system", "你是一个中文关键词提取助手。"),
(
"human",
"""
请从文章中提取 5-8 个关键词。
要求:
1. 只返回 JSON 数组。
2. 不要返回 Markdown。
3. 不要添加解释。
示例:
["关键词1", "关键词2", "关键词3"]
文章:
{article}
"""
)
])
控制变量边界
不推荐这种prompt
css
请总结这篇文章:{article}
如果文章很长,或者文章里本身包含指令,模型可能混淆"系统任务"和"文章内容"。
可以这么做
css
请总结下面文章。
文章开始:
{article}
文章结束。
避免过度 Prompt
prompt并不是越长越好,比如下面这样:
你必须非常准确、非常专业、非常认真、非常仔细、非常有逻辑......
并不一定会带来稳定的提升,反而会增加token的消耗
更好的写法是明确约束条件:
javascript
只基于文章内容回答
如果文章没有提到,回答"文章未提及"
输出 3 条以内
只返回 JSON
少写情绪化形容词
多写可执行约束
3.10 Prompt 组合
Prompt 组合指的是:把多个 Prompt 片段或 Prompt 组件组合成更复杂的 Prompt 或工作流。
常见的组合方式有下面几种:
字符串片段组合
这是最简单的组合方式
ini
role = "你是一个专业的中文文章分析助手。"
task = """
任务:
请分析下面文章。
"""
format_instruction = """
输出格式:
{
"summary": "...",
"keywords": ["...", "..."]
}
"""
template = f"""
{role}
{task}
{format_instruction}
文章:
{{article}}
"""
prompt = ChatPromptTemplate.from_messages([
("human", template)
])
这种方式简单直接,但如果片段越来越多,就容易失控。
消息结构组合
ini
prompt = ChatPromptTemplate.from_messages([
("system", "你是一个专业的中文文章分析助手。"),
("human", "任务:请总结下面文章。"),
("human", "要求:只基于文章内容,不要编造信息。"),
("human", "文章:{article}")
])
这种结构更加推荐
3.11 实战:构建 Prompt Library
在第二章中,我们写了一个workflow,里面的prompt都写在业务代码中,后续如果业务代码再复杂,就会导致文件越来越长,这里根据前面学习的,把 Prompt 从业务流程里抽出来,形成 Prompt Library。
第一步:设计目录结构
新建目录结构:
lua
langchain/
prompt_library/
__init__.py
article_prompts.py
router_prompts.py
output_prompts.py
debug.py
具体职责划分:
lua
article_prompts.py:文章处理 Prompt
router_prompts.py:任务识别 Prompt
output_prompts.py:最终回答 Prompt
debug.py:Prompt 调试工具
第二步:抽取文章处理 Prompt
文件:article_prompts.py
里面放文章处理相关 Prompt:
ini
SUMMARY_PROMPT_VERSION = "summary_v1"
KEYWORDS_PROMPT_VERSION = "keywords_v1"
CATEGORY_PROMPT_VERSION = "category_v1"
SENTIMENT_PROMPT_VERSION = "sentiment_v1"
summary_prompt = ChatPromptTemplate.from_messages([...])
keywords_prompt = ChatPromptTemplate.from_messages([...])
category_prompt = ChatPromptTemplate.from_messages([...])
sentiment_prompt = ChatPromptTemplate.from_messages([...])
这样 summary_prompt、keywords_prompt 不再散落在 workflow 文件里。
第三步:抽取任务路由 Prompt
文件:router_prompts.py
ini
TASK_IDENTIFY_PROMPT_VERSION = "task_identify_v1"
task_identify_prompt = ChatPromptTemplate.from_messages([...])
这个 Prompt 只负责一件事:
根据用户指令判断任务类型
比如:
rust
请总结这篇文章 -> summarize
请提取关键词 -> keywords
请判断类别 -> category
第四步:抽取最终回答 Prompt
文件:output_prompts.py
ini
FINAL_ANSWER_PROMPT_VERSION = "final_answer_v1"
final_answer_prompt = ChatPromptTemplate.from_messages([...])
它负责把 workflow 的结构化结果整理成最终用户可读回答。
第五步:在 workflow 中复用 Prompt
现在 summary_chain.py 里不再定义 Prompt,而是导入:
javascript
from prompt_library import (
category_prompt,
final_answer_prompt,
keywords_prompt,
sentiment_prompt,
summary_prompt,
task_identify_prompt,
)
原来的 chain 逻辑基本不变:
ini
summary_chain = with_retry_then_fallback(
summary_prompt | primary_model | parser,
summary_prompt | backup_model | parser,
)
这说明一次好的重构不一定要改业务流程。
这次只是把 Prompt 管理方式变清晰了
第六步:增加 Prompt Version
Prompt Library 里统一维护版本:
ini
SUMMARY_PROMPT_VERSION = "summary_v1"
KEYWORDS_PROMPT_VERSION = "keywords_v1"
TASK_IDENTIFY_PROMPT_VERSION = "task_identify_v1"
并在 __init__.py 里汇总:
css
PROMPT_VERSIONS = {
"summary": SUMMARY_PROMPT_VERSION,
"keywords": KEYWORDS_PROMPT_VERSION,
"category": CATEGORY_PROMPT_VERSION,
"sentiment": SENTIMENT_PROMPT_VERSION,
"task_identify": TASK_IDENTIFY_PROMPT_VERSION,
"final_answer": FINAL_ANSWER_PROMPT_VERSION,
}
在 chain 中也可以写入 metadata:
ini
summary_chain = summary_chain.with_config(
metadata={"prompt_version": SUMMARY_PROMPT_VERSION}
)
这样后续调试、追踪、评测时,可以知道本次调用用了哪个 Prompt 版本。
第七步:增加 Debug 方法
python
def preview_prompt(prompt, input_data: dict) -> None:
prompt_value = prompt.invoke(input_data)
print("=== Prompt String ===")
print(prompt_value.to_string())
print("\n=== Prompt Messages ===")
for message in prompt_value.to_messages():
print(f"[{message.type}] {message.content}")
使用方式:
css
preview_prompt(task_identify_prompt, {
"article": demo_article[:500],
"instruction": demo_instruction,
})
调试 Prompt 时,不要只看模型输出。
更重要的是先看:
真正发给模型的 Prompt 长什么样
变量有没有填进去
消息结构是否正确
然后运行python summary_chain.py
css
#输出
Prompt versions: {'summary': 'summary_v1', 'keywords': 'keywords_v1', 'category': 'category_v1', 'sentiment': 'sentiment_v1', 'task_identify': 'task_identify_v1', 'final_answer': 'final_answer_v1'}
=== Prompt String ===
System: 你是一个任务路由助手,根据用户指令选择最合适的文章处理链。
Human: 请根据用户指令判断任务类型。
只能返回下面 5 个值之一:
summarize
keywords
category
sentiment
analysis
最终我们将prompt从散落在业务代码里的代码块抽离出来,变成了可以统一维护、单独测试、版本化迭代的工程化资产,为后续的agent健壮的工程能力打下了一个坚实的基础。
完整代码可以看这里
四、Chat Model API:统一调用不同大模型
4.1 为什么需要 Chat Model API
目前大模型厂商可以说是非常多的,国外知名的御三家openai、 Google 和 Anthropic ,还有国内的豆包、千文、kimi、智谱、deepseek等,也是非常之多的,他们的消息格式、参数名称、返回结构都大不一样,如果没有一个统一的接口,为了适配各家模型厂商,就要写大量的胶水代码去适配,切换模型的成本也会变得非常之高。
于是langchian提出了这样的解决方案:不直接操作 Provider SDK,而是统一封装成 Chat Model ,每一个Provider 都可以理解为一个大模型厂商, LangChain 为他们都提供一个 ChatModel ,但是他们对外暴露的接口都完全一致,都是 Runnable ,于是他们就可以 无缝参与LCEL工作流, 与 Prompt、Output Parser、Retriever 等组件自由组合
4.2 第一次调用 Chat Model
看下这个简单的实例:
scss
def create_model(**overrides) -> ChatOpenAI:
config = {
"model": required_env("LLM_MODEL_ID"),
"api_key": required_env("LLM_API_KEY"),
"base_url": required_env("LLM_BASE_URL"),
}
config.update(overrides)
return ChatOpenAI(**config)
def demo_first_call() -> None:
model = create_model()
response = model.invoke("请介绍一下 LangChain")
print(response.content)
def required_env(name: str) -> str:
value = os.getenv(name)
if not value:
raise ValueError(f"Missing required environment variable: {name}")
return value
我的配置文件写在.env中,通过required_env方法调用os.getenv去读取配置,然后一起塞给ChatOpenAI,这些也都比较简单,最后调用invoke方法表示一次同步的模型调用,这里的invoke和前面介绍的unnable接口完全是一回事,这个model也是一个Runnable接口
4.3 Chat Model 的输入与输出
目前常见的输入有两种:直接传入字符串和传入带角色的消息列表。
-
字符串输入
vbscriptresponse = model.invoke("你好") print(response.content)这种适合简单的问答,不适合多轮对话
-
消息列表
arduino
response = model.invoke(
[
("system", "你是一名 Python 教师,请用简洁的语言回答。"),
("human", "介绍一下 Runnable。"),
]
)
前面介绍过prompt,prompt调用from_messages之后,是一个runnable接口,可以被纳入LCEL工作流,同样的
model.invoke返回的也是一个runnable接口,也可以被纳入LCEL工作流,它实际上是一个AIMessage对象,接下来介绍下这个对象上的方法和属性:
-
content
这个属性主要是模型返回的内容,
print(response.content)比如这行代码,直接回返回大模型返回的字符串 -
tool_calls
这里是给大模型调用的工具数组
-
usage_metadata
模型调用的 Token 使用情况
-
response_metadata
模型厂商返回的其他元数据 ,包含模型名称、请求ID和token消耗量等。
4.4 模型参数
前面的简单实例中,已经传入了三个基本的模型参数model、api_key和base_url,当然模型肯定不止这几个参数,比如下面这几个
ini
model = ChatOpenAI(
model="deepseek-chat",
api_key="...",
base_url="...",
temperature=0.2,
max_tokens=300,
timeout=60,
max_retries=2,
)
接下来详细介绍下:
-
temperature,控制回答的随机性,对于一些代码生成等比较眼睛的工作,这个参数建议设置的很低,对于一些文字创作类的,就建议设置的很高了,有些推理模型也会忽略或者限制这个参数
-
max_tokens 控制模型输出的长度,最大多少token
-
timeout 控制请求等待最大时间
-
max_retries 设置模型最大重试次数
-
stream()
流失输出
luafor chunk in model.stream("介绍一下 LangChain"): print(chunk.content, end="", flush=True) -
batch() 批量处理多个相互独立的输入
cssresponses = model.batch( [ "解释 Chat Model", "解释 Prompt", "解释 Output Parser", ] ) -
ainvoke() 异步调用
iniresponse = await model.ainvoke("为什么异步调用适合 Web 服务?")
4.5 接入不同模型厂商
LangChain 没有使用一个类处理所有模型,而是为不同 Provider 提供独立的集成包和 Chat Model 类。
javascript
from langchain_openai import ChatOpenAI
from langchain_anthropic import ChatAnthropic
from langchain_google_genai import ChatGoogleGenerativeAI
from langchain_deepseek import ChatDeepSeek
这些类的初始化方式略有不同,但都实现了 LangChain 的 BaseChatModel 接口,因此拥有相似的调用方式:
vbscript
response = model.invoke("你好")
print(response.content)
也可以使用:
scss
model.stream(...)
model.batch(...)
await model.ainvoke(...)
常见的Provider有下面这些
| Provider | LangChain 类 | 集成包 | 主要特点 |
|---|---|---|---|
| OpenAI | ChatOpenAI |
langchain-openai |
工具调用和结构化输出成熟 |
| Anthropic | ChatAnthropic |
langchain-anthropic |
长文本、写作和推理能力 |
| Gemini | ChatGoogleGenerativeAI |
langchain-google-genai |
多模态及 Google 生态 |
| DeepSeek | ChatDeepSeek |
langchain-deepseek |
中文、推理和性价比 |
| OpenAI-compatible | ChatOpenAI |
langchain-openai |
通过 base_url 接入兼容服务 |
4.6 实战:可切换模型的聊天程序
本章节写一个小案例,做一个支持流失输出、可以切换模型的机器人
配置加载
现在.env中定义好所需模型的key、baseurl以及模型名称
python
def required_env(*names: str) -> str:
"""Return the first configured environment variable from names."""
for name in names:
value = os.getenv(name)
if value:
return value
joined_names = ", ".join(names)
raise ValueError(f"Missing environment variable. Configure one of: {joined_names}")
#后续加载
model=required_env("LLM_MODEL_ID", "DEEPSEEK_MODEL_ID"),
api_key=required_env("LLM_API_KEY", "DEEPSEEK_API_KEY"),
base_url=required_env("LLM_BASE_URL", "DEEPSEEK_BASE_URL"),
模型工厂
定义一个模型工厂函数,支持切换gpt和deepseek
ini
def create_chat_model(provider: str) -> ChatOpenAI:
"""Create GPT or DeepSeek through their OpenAI-compatible endpoints."""
provider = provider.strip().lower()
timeout = int(os.getenv("LLM_TIMEOUT", "60"))
if provider == "deepseek":
return ChatOpenAI(
model=required_env("LLM_MODEL_ID", "DEEPSEEK_MODEL_ID"),
api_key=required_env("LLM_API_KEY", "DEEPSEEK_API_KEY"),
base_url=required_env("LLM_BASE_URL", "DEEPSEEK_BASE_URL"),
timeout=timeout,
)
if provider == "gpt":
return ChatOpenAI(
model=required_env("LLM_MODEL_ID_GPT", "GPT_MODEL_ID"),
api_key=required_env("LLM_API_KEY_GPT", "OPENAI_API_KEY"),
base_url=required_env("LLM_BASE_URL_GPT", "OPENAI_BASE_URL"),
timeout=int(os.getenv("LLM_TIMEOUT_GPT", str(timeout))),
)
supported = ", ".join(SUPPORTED_PROVIDERS)
raise ValueError(f"Unsupported provider: {provider}. Supported: {supported}")
就是根据参数provider,来加载.env中的不同模型
会话管理
当前这个机器人需要三个会话状态,provider、model和messages
每次调用模型时都会把完整消息列表发送过去,所以模型能够理解上下文。
python
class ChatSession:
"""Manage the selected model and a provider-independent message history."""
def __init__(self, provider: str) -> None:
self.provider = ""
self.model: ChatOpenAI
self.messages: list[BaseMessage] = []
self.clear()
self.switch_model(provider)
def switch_model(self, provider: str) -> None:
"""Switch models while preserving the current conversation history."""
model = create_chat_model(provider)
self.provider = provider.strip().lower()
self.model = model
def clear(self) -> None:
"""Clear conversation history but retain the system instruction."""
self.messages = [SystemMessage(content=SYSTEM_PROMPT)]
def stream_reply(self, user_input: str) -> str:
"""Stream one answer and append the completed turn to history."""
user_message = HumanMessage(content=user_input)
self.messages.append(user_message)
answer_parts: list[str] = []
try:
print(f"{self.provider}> ", end="", flush=True)
for chunk in self.model.stream(self.messages):
if not isinstance(chunk.content, str):
continue
answer_parts.append(chunk.content)
print(chunk.content, end="", flush=True)
print()
except Exception:
self.messages.pop()
raise
answer = "".join(answer_parts)
self.messages.append(AIMessage(content=answer))
return answer
同时还支持动态切换模型switch_model以及流式回答stream_reply
命令处理
python
def handle_command(command: str, session: ChatSession) -> bool:
"""Handle a slash command and return False when the program should exit."""
command, _, argument = command.partition(" ")
command = command.lower()
argument = argument.strip().lower()
if command in {"/exit", "/quit"}:
return False
if command == "/help":
print_help()
return True
if command == "/models":
print("当前模型:", session.provider)
print("可选模型:", ", ".join(SUPPORTED_PROVIDERS))
return True
if command == "/clear":
session.clear()
print("对话历史已清空。")
return True
if command == "/model":
if not argument:
print("用法:/model gpt 或 /model deepseek")
return True
try:
session.switch_model(argument)
except (ImportError, ValueError) as exc:
print(f"切换失败:{exc}")
return True
print(f"已切换到 {session.provider},当前对话历史已保留。")
return True
print(f"未知命令:{command}。输入 /help 查看可用命令。")
return True
五、 Structured Output API,让大模型输出可用的数据
5.1 为什么需要 Structured Output
我们平时用变成语言写的方法,固定的输入就会有一个固定更多输出,而LLM模型不是这样,每一步生成下一个token,都是按照词汇概率分布随机采样,同一个prompt、同一个模型输出的结果也有几率不一样的,模型没有遵守格式的强制逻辑,只会模仿训练数据里的文字,这就会导致llm的输出很不稳定。
langchain的 Structured Output 正是为了解决这个问题而提出来的,通过工程能力约束llm的输出,让我们的agent有一个较为稳定的输出。
常见的工程化解决方案,需要结合prompt、来源引用、业务规则或人工审核共同去保障
比如依赖三层可靠性模型:
markdown
Prompt 格式要求
↓
OutputParser 解析与校验
↓
模型原生 Structured Output / Tool Calling
这里第一层prompt主要职责是告诉模型应该怎么输出,简单通用,这种方式适用于所有的模型,只是自然语言层面的要求;OutputParser解析并且校验输出的文本,可以将文本转换为JSON、列表或者 Pydantic 对象, 这一层能够发现问题,但是这里是在模型输出之后的,无法阻止模型产生错误的格式; 原生 Structured Output 这一层是在模型或者API层按照Schema生成结果,而langchain使用 with_structured_output() 对这些能力进行统一封装 ,这种方式尽量在模型能力或 API 协议层约束结果,通常比单纯依靠 Prompt 更稳定。但不同模型可能分别使用严格 JSON Schema、Tool Calling 或 Prompt 模拟,因此实际保证并不完全相同。
在实际的项目中,通常会这么做:
scss
定义 Schema
↓
Prompt 说明字段的业务含义
↓
with_structured_output() 约束模型输出
↓
Pydantic / Parser 做最终校验
↓
失败重试、降级或人工审核
↓
业务程序消费数据
5.2 OutputParser
OutputParser 就是将模型的输出转换为程序更容易处理的数据
ini
from langchain_core.output_parsers import StrOutputParser
chain = model | StrOutputParser()
res = chain.invoke("介绍 LangChain")
这里管道,输出的就是字符串,
如果想输出字典,可以这么做:
ini
llm = ChatOpenAI(temperature=0)
parser = JsonOutputParser()
prompt = PromptTemplate(
template="只用json回答,介绍LangChain,key包含name、desc\n问题:{q}",
input_variables=["q"]
)
chain = prompt | llm | parser
res = chain.invoke({"q": "LangChain是什么"})
print(res["name"], res["desc"])
如果想要使用 Pydantic 做校验,可以这么做:
ini
class LangChainInfo(BaseModel):
name: str = Field(description="技术名称")
introduce: str = Field(description="简短介绍")
core_feature: list[str] = Field(description="核心功能列表")
applicable_scenario: str = Field(description="适用场景")
# 初始化 Json 解析器,绑定上面的数据模型
parser = JsonOutputParser(pydantic_object=LangChainInfo)
prompt = PromptTemplate(
template="""请按照 JSON 格式介绍 LangChain,不要额外输出解释文字。
{format_instructions}
用户问题:{query}
""",
input_variables=["query"],
# 把解析器自带的格式规则注入提示词
partial_variables={"format_instructions": parser.get_format_instructions()}
)
# 初始化大模型
llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0)
# 管道:prompt -> llm -> JsonOutputParser
chain = prompt | llm | parser
result = chain.invoke({"query": "介绍 LangChain"})
langchain还提供了一个 get_format_instructions 方法,根据定义的结构自定生成提示词,
makefile
chain = prompt | model | parser
result = chain.invoke(
{
"format_instructions": parser.get_format_instructions(),
"text": "星河科技招聘 Python 工程师。",
}
)
这段文字塞进 Prompt,大模型就会严格按照你定义的 Pydantic 结构返回 JSON,防止格式错乱、解析报错
JsonOutputParser 可以与 LCEL 的 stream() 配合,逐步解析模型生成的 JSON。
python
parser = JsonOutputParser()
prompt = ChatPromptTemplate.from_template(
"""
从招聘信息中提取以下字段:
- job_title
- company
- salary_min
- salary_max
- skills
{format_instructions}
只返回 JSON,不要添加解释。
招聘信息:
{text}
"""
).partial(
format_instructions=parser.get_format_instructions()
)
chain = prompt | model | parser
for chunk in chain.stream(input_data):
latest_result = chunk
print("当前解析结果:")
print(chunk)
print("-" * 50)
5.3 PydanticOutputParser:解析并校验数据模型
JsonOutputParser 可以将模型的输出转换为字典,但是字典本身并不能严格保证字段类型和业务规则,前面介绍 JsonOutputParser 时,也使用过使用过Pydantic 模型,
ini
class LangChainInfo(BaseModel):
name: str = Field(description="技术名称")
introduce: str = Field(description="简短介绍")
core_feature: list[str] = Field(description="核心功能列表")
applicable_scenario: str = Field(description="适用场景")
同时还有一个方法PydanticOutputParser专门用于 输出 Pydantic 模型实例 ,来规范化parse,接下来详细的介绍下它。
Pydantic是python最主流的数据校验、结构化建模的第三方库,通过python的类型注解来完成自动校验数据、自动转换类型、一键系列化JSON
ini
from pydantic import BaseModel, Field
class JobInfo(BaseModel):
title: str = Field(description="职位名称")
company: str = Field(description="公司名称")
salary_min: int | None = Field(
description="最低月薪,单位为元"
)
salary_max: int | None = Field(
description="最高月薪,单位为元"
)
skills: list[str] = Field(
description="技能要求"
)
比如这个类,就约束了五个字段的类型,比如title必须是字符串,salary_min必须是int类型或者是None,skills必须是一个数组字符串;然后又通过Field做了精细化字段约束,description 会被 get_format_instructions() 读取,自动生成给大模型的提示词 。
后续创建对象就可以这样:
ini
job = JobInfo(
title="Python 后端工程师",
company="星河科技",
salary_min=25000,
salary_max=35000,
skills=["Python", "FastAPI", "MySQL"],
)
使用 PydanticOutputParser 来规范化输出,可以分为下面几个步骤:
- 定义 Pydantic 模型
- 创建Parse
- 将格式说明注入Prompt
- 使用LCEL组合Prompt、模型和Parser
看下实例:
ini
class JobInfo(BaseModel):
title: str = Field(
description="职位名称"
)
company: str = Field(
description="公司名称"
)
salary_min: int | None = Field(
default=None,
description="最低月薪,单位为元",
)
salary_max: int | None = Field(
default=None,
description="最高月薪,单位为元",
)
skills: list[str] = Field(
default_factory=list,
description="职位要求的技能列表",
)
model = ChatDeepSeek(
model="deepseek-chat",
temperature=0,
)
parser = PydanticOutputParser(
pydantic_object=JobInfo
)
prompt = ChatPromptTemplate.from_template(
"""
从下面的招聘信息中提取数据。
提取要求:
- 薪资统一转换为每月人民币元
- 原文没有提供薪资时使用 null
- 原文没有提供技能时使用空列表
- 不要补充原文中没有的信息
{format_instructions}
招聘信息:
{text}
"""
).partial(
format_instructions=(
parser.get_format_instructions()
)
)
chain = prompt | model | parser
result = chain.invoke(
{
"text": """
星河科技招聘 Python 后端工程师,
月薪 25k-35k,要求熟悉 Python、
FastAPI 和 MySQL。
"""
}
)
创建parser的时候,通过 pydantic_object 去指定 目标 Pydantic 模型,创建prompt时, 通过parser.get_format_instructions()去约束prompt,会去读取description塞到prompt里面,外面再包裹一个 partial() , 提前绑定格式说明 ,调用链的时候,直接传递照片文本即可
arduino
result = chain.invoke(
{"text": "星河科技招聘 Python 工程师。"}
)
5.4 XMLOutputParser:处理 XML 输出
现在常见的场景中,大模型的输出格式基本都是JSON,但是也会需要用到XML格式的,比如一些文档数据,XML就非常适合带标签和层次结构的文档,比如下面这种:
xml
<article>
<title>LangChain 入门</title>
<section>
<heading>Prompt</heading>
<paragraph>Prompt 用于组织模型输入。</paragraph>
</section>
<section>
<heading>Model</heading>
<paragraph>Model 用于调用大语言模型。</paragraph>
</section>
</article>
langchain的 XMLOutputParser 就专门用于将大模型生成的XML转换成字典结构
看下实例:
python
model = ChatDeepSeek(
model="deepseek-chat",
temperature=0,
)
parser = XMLOutputParser(
tags=[
"job",
"title",
"company",
"salary",
"min",
"max",
"skills",
"skill",
]
)
prompt = ChatPromptTemplate.from_template(
"""
从下面的招聘信息中提取职位信息。
要求:
- 只返回 XML
- 不要返回 Markdown 代码块
- 不要添加解释
- 薪资统一转换为人民币元
- 不要补充原文中没有的信息
{format_instructions}
XML 结构必须为:
<job>
<title>职位名称</title>
<company>公司名称</company>
<salary>
<min>最低月薪</min>
<max>最高月薪</max>
</salary>
<skills>
<skill>技能名称</skill>
</skills>
</job>
招聘信息:
{text}
"""
).partial(
format_instructions=(
parser.get_format_instructions()
)
)
chain = prompt | model | parser
result = chain.invoke(
{
"text": """
星河科技招聘 Python 后端工程师,
月薪 25k-35k,要求熟悉 Python、
FastAPI 和 MySQL。
"""
}
print("结果类型:", type(result))
print("解析结果:", result)
tags 用于向模型说明允许或期望使用的 XML 标签。
最后的输出结果:
arduino
结果类型: <class 'dict'>
解析结果: {'job': [{'title': 'Python 后端工程师'}, {'company': '星河科技'}, {'salary': [{'min': '25000'}, {'max': '35000'}]}, {'skills': [{'skill': 'Python'}, {'skill': 'FastAPI'}, {'skill': 'MySQL'}]}]}
(.venv) PS H:\project\blog\blog\ai应用开发\code\agent\langchain\text>
5.5 OutputFixingParser:让模型修复格式
在parser遇到模型生成的错误格式时,就会抛出 OutputParserException 的错误,langchain在遇到这汇总解析失败的场景下,提出了一个容错的api, OutputFixingParser,自动修复的能力的输出解释器。 OutputFixingParser 本身并不定义目标数据结构。它需要包装一个真正负责解析和校验的基础 Parser,比如下面这样:
ini
fixing_parser = OutputFixingParser.from_llm(
llm=repair_model,
parser=base_parser,
max_retries=2,
)
看下这个完整的案例:
ini
class JobInfo(BaseModel):
title: str = Field(
description="职位名称"
)
company: str | None = Field(
default=None,
description="公司名称,未知时返回 null",
)
salary_min: int | None = Field(
default=None,
description="最低月薪,单位为元",
)
salary_max: int | None = Field(
default=None,
description="最高月薪,单位为元",
)
skills: list[str] = Field(
default_factory=list,
description="技能字符串数组",
)
model = ChatDeepSeek(
model="deepseek-chat",
temperature=0,
)
base_parser = PydanticOutputParser(
pydantic_object=JobInfo
)
fixing_parser = OutputFixingParser.from_llm(
llm=model,
parser=base_parser,
max_retries=2,
)
prompt = ChatPromptTemplate.from_template(
"""
从下面的招聘信息中提取数据。
要求:
- 薪资转换成以元为单位的整数
- 未知字段返回 null
- skills 必须是字符串数组
- 不要补充原文不存在的信息
{format_instructions}
招聘信息:
{text}
"""
).partial(
format_instructions=(
base_parser.get_format_instructions()
)
)
chain = prompt | model | fixing_parser
result = chain.invoke(
{
"text": """
星河科技招聘 Python 后端工程师,
月薪 25k-35k,要求熟悉 Python、
FastAPI 和 MySQL。
"""
}
)
这里定义了一个base_parser,通过pydantic_object来约束模型的输入输出,然后定义一个fixing_parser,在后续链式调用prompt | model | fixing_parser,使用的是fixing_parser。接下来详细的描述下这个执行过程:
第一次模型输出正确时:
业务模型生成输出
↓
fixing_parser
↓
base_parser 解析成功
↓
直接返回结果
只有当第一次模型输出不正确时, OutputFixingParser 才会介入
业务模型生成错误输出
↓
base_parser 解析失败
↓
OutputFixingParser 捕获异常
↓
调用 repair_model
↓
得到修复结果
↓
base_parser 再次解析
5.6 RetryOutputParser:携带原始上下文重新生成
前一章节介绍的OutputFixingParser,只有当输出的格式有问题的时候,才会考虑OutputFixingParser,当仅凭错误输出没法修复的时候,就要用到RetryOutputParser, 会携带原始 Prompt 一起交给重试模型 ,让模型重新生成,这个api核心目的不是修复格式问题,而是让模型重新生成
langchian还提供了一个api,会将错误信息一起发送给大模型,让大模型重新生成,RetryWithErrorOutputParser ,看下下面的表格,这三个api的区别
| Parser | 重试依据 | 更适合解决 |
|---|---|---|
OutputFixingParser |
格式规则、错误输出、解析错误 | JSON 损坏、字段形式错误 |
RetryOutputParser |
原始 Prompt、错误输出、格式规则 | 字段缺失、任务理解不完整 |
RetryWithErrorOutputParser |
原始 Prompt、错误输出、解析错误、格式规则 | 需要同时理解原任务和具体错误 |
看下下面这个完整的案例:
ini
class PersonInfo(BaseModel):
name: str = Field(
description="姓名"
)
age: int = Field(
description="年龄,必须是整数"
)
job: str = Field(
description="职业"
)
skills: list[str] = Field(
default_factory=list,
description="技能字符串数组",
)
model = ChatDeepSeek(
model="deepseek-chat",
temperature=0,
)
# 1. 创建真正负责解析和校验的 Parser
base_parser = PydanticOutputParser(
pydantic_object=PersonInfo
)
# 2. 创建 RetryOutputParser
retry_parser = RetryOutputParser.from_llm(
llm=model,
parser=base_parser,
max_retries=2,
)
# 3. 定义原始 Prompt
prompt = ChatPromptTemplate.from_template(
"""
从下面的个人介绍中提取信息。
要求:
- age 必须是整数
- skills 必须是字符串数组
- 不要遗漏原文中存在的信息
- 不要猜测原文中不存在的信息
{format_instructions}
个人介绍:
{text}
"""
).partial(
format_instructions=(
base_parser.get_format_instructions()
)
)
input_data = {
"text": """
张三今年 28 岁,是一名 Python 后端工程师,
熟悉 Python、FastAPI 和 MySQL。
"""
}
# 4. 生成完整 PromptValue
prompt_value = prompt.invoke(input_data)
# 5. 第一次调用模型
response = model.invoke(prompt_value)
print("第一次模型输出:")
print(response.content)
try:
# 6. 使用 RetryOutputParser 解析
# 如果第一次解析失败,它会携带 prompt_value 重试
result = retry_parser.parse_with_prompt(
completion=response.content,
prompt_value=prompt_value,
)
print("\n最终结果类型:")
print(type(result))
print("\n最终结果:")
print(result)
print("\n字典形式:")
print(result.model_dump())
except OutputParserException as exc:
print("达到最大重试次数后仍然失败:")
print(exc)
下面这个案例是使用RetryWithErrorOutputParser的
ini
class JobInfo(BaseModel):
title: str
company: str
salary_min: int = Field(
description="最低月薪整数"
)
salary_max: int = Field(
description="最高月薪整数"
)
skills: list[str]
model = ChatDeepSeek(
model="deepseek-chat",
temperature=0,
)
base_parser = PydanticOutputParser(
pydantic_object=JobInfo
)
retry_parser = (
RetryWithErrorOutputParser.from_llm(
llm=model,
parser=base_parser,
max_retries=2,
)
)
prompt = ChatPromptTemplate.from_template(
"""
从招聘信息中提取数据。
{format_instructions}
招聘信息:
{text}
"""
).partial(
format_instructions=(
base_parser.get_format_instructions()
)
)
input_data = {
"text": """
星河科技招聘 Python 后端工程师,
月薪 25000-35000 元,
要求熟悉 Python 和 FastAPI。
"""
}
prompt_value = prompt.invoke(input_data)
bad_completion = """
{
"title": "Python 后端工程师",
"company": "星河科技",
"salary_min": "不知道",
"skills": "Python、FastAPI"
}
"""
try:
result = retry_parser.parse_with_prompt(
completion=bad_completion,
prompt_value=prompt_value,
)
print(result)
print(result.model_dump())
except OutputParserException as exc:
print("重试后仍然失败:")
print(exc)
一般来说,RetryWithErrorOutputParser 比 RetryOutputParser 获得的诊断信息更多,但 Prompt 也更长 ,消耗的token自然也是更长的。
5.7 with_structured_output()
with_structured_output() 是 ChatModel 提供的结构化输出接口 , 它接收一份 Schema,并返回一个新的 Runnable。这个 Runnable 会利用模型提供商支持的结构化输出能力,让模型按照指定结构生成结果。
和前面章节介绍的OutputParser相比,主要是约束发生的位置不一样,前面介绍的OutputParser发生的位置在模型生成后,而 with_structured_output 起作用的位置是在模型生成之前,流程是这样的
javascript
Schema 交给 ChatModel
↓
模型通过 Structured Output、
Tool Calling 或 JSON Mode 生成结果
↓
LangChain 转换和校验
↓
Python 对象
因此,当模型原生支持结构化输出时,一般优先使用 with_structured_output()。
看下简单的实例:
ini
class JobInfo(BaseModel):
title: str = Field(
description="职位名称"
)
company: str | None = Field(
default=None,
description="公司名称,未知时返回 null",
)
salary_min: int | None = Field(
default=None,
description=(
"最低月薪,单位为人民币元,"
"只返回整数,未知时返回 null"
),
)
salary_max: int | None = Field(
default=None,
description=(
"最高月薪,单位为人民币元,"
"只返回整数,未知时返回 null"
),
)
skills: list[str] = Field(
default_factory=list,
description="技能字符串数组",
)
model = ChatDeepSeek(
model="deepseek-chat",
temperature=0,
)
structured_model = model.with_structured_output(
JobInfo
)
result = structured_model.invoke(
"""
从下面的招聘信息中提取数据。
不要补充原文中不存在的信息。
招聘信息:
星河科技招聘 Python 后端工程师,
月薪 25k-35k,要求熟悉 Python、
FastAPI 和 MySQL。
"""
)
改成langchain推崇的LCEL链式操作,是这样的:
python
class JobInfo(BaseModel):
title: str
company: str | None = None
salary_min: int | None = None
salary_max: int | None = None
skills: list[str] = Field(
default_factory=list
)
model = ChatDeepSeek(
model="deepseek-chat",
temperature=0,
)
structured_model = model.with_structured_output(
JobInfo
)
prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"""
你负责提取招聘信息。
薪资统一转换成以人民币元为单位的整数。
原文没有提供的字段返回 null。
不要猜测原文中没有的信息。
""",
),
(
"human",
"招聘信息:\n\n{text}",
),
]
)
chain = prompt | structured_model
result = chain.invoke(
{
"text": """
星河科技招聘 Python 后端工程师,
月薪 25k-35k,要求熟悉 Python 和 MySQL。
"""
}
)
5.8 JSON Schema:跨语言的数据契约
JSON Schema 是一种用 JSON 描述 JSON 数据结构的标准。
普通 JSON 用来保存数据:
json
{
"title": "Python 后端工程师",
"company": "星河科技",
"salary_min": 25000,
"skills": ["Python", "FastAPI"]
}
JSON Schema 用来描述这些数据必须满足什么规则:
json
{
"type": "object",
"properties": {
"title": {
"type": "string"
},
"salary_min": {
"type": "integer"
},
"skills": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": ["title", "salary_min", "skills"]
}
JSON Schema有这么一些核心的字段来描述对象
- type,定义对象的类型,比如object、array、string、number等
- properties,定义对象字段
- required:定义必填字段
- description:描述字段语义
- enum:固定字段只能从集合中选择
- 数字约束,约束数字的最大值、最小值,比如minimum,maximum
- 字符串约束,约束字符串的长度,minLength,maxLength
- additionalProperties,一个布尔值,默认为true,允许对象出现未定义字段
看下JSON Schema结合with_structured_output的案例
python
job_info_schema = {
"title": "JobInfo",
"description": "从招聘文本中提取职位信息",
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "职位名称",
},
"company": {
"type": [
"string",
"null",
],
"description": (
"公司名称,未知时返回null"
),
},
"salary_min": {
"type": [
"integer",
"null",
],
"minimum": 0,
"description": (
"最低月薪,单位为人民币元;"
"将25k转换成25000;"
"未知时返回null"
),
},
"salary_max": {
"type": [
"integer",
"null",
],
"minimum": 0,
"description": (
"最高月薪,单位为人民币元;"
"将35k转换成35000;"
"未知时返回null"
),
},
"skills": {
"type": "array",
"items": {
"type": "string"
},
"description": (
"技能字符串数组,"
"未知时返回空数组"
),
},
},
"required": [
"title",
"company",
"salary_min",
"salary_max",
"skills",
],
"additionalProperties": False,
}
model = ChatDeepSeek(
model="deepseek-chat",
temperature=0,
)
structured_model = model.with_structured_output(
job_info_schema
)
prompt = ChatPromptTemplate.from_messages(
[
(
"system",
"""
你负责提取招聘信息。
不要猜测原文中不存在的信息。
薪资统一转换成以人民币元为单位的整数。
""",
),
(
"human",
"招聘信息:\n\n{text}",
),
]
)
chain = prompt | structured_model
result = chain.invoke(
{
"text": """
星河科技招聘 Python 后端工程师,
月薪25k-35k,要求熟悉Python、
FastAPI和MySQL。
"""
}
)
print("结果类型:", type(result))
print("完整结果:", result)
print("职位:", result["title"])
print("公司:", result["company"])
print("最低月薪:", result["salary_min"])
print("技能:", result["skills"])
结果类型: <class 'dict'>
完整结果: {'title': 'Python 后端工程师', 'company': '星河科技', 'salary_min': 25000, 'salary_max': 35000, 'skills': ['Python', 'FastAPI', 'MySQL']}
职位: Python 后端工程师
公司: 星河科技
最低月薪: 25000
技能: ['Python', 'FastAPI', 'MySQL']
5.9 Structured Output 最佳实践
Structured Output 的目标不只是让模型返回 JSON,而是让模型输出能够被业务程序稳定、安全地消费。
一个结构化结果通常需要经过多个层次:
javascript
模型生成结构化结果
↓
JSON语法解析
↓
Schema与类型校验
↓
业务规则校验
↓
事实与来源校验
↓
进入数据库、API或工作流
任何一层通过,都不代表后面的层次也一定正确。
所以一个比较好的结构化输出需要同时关注Schema设计、字段语义、缺失值约定、确定性后处理、事实依据等
接下来有这么几个规则需要注意下:
Schema 要小而明确
Schema 越大,不一定代表提取能力越强。
如果一次要求模型返回几十个字段,会带来以下问题:
- Prompt 和 Schema 占用更多 Token
- 模型更容易遗漏字段
- 相似字段容易混淆
- 可选字段大量返回
null - 嵌套结构更容易损坏
- Provider 可能不支持复杂 Schema
- 校验失败时难以定位原因
- 修改一个字段可能影响整个结果
根据业务阶段拆分Schema
不要试图通过一个 Schema 完成所有任务。可以按工作流阶段拆分:
第一步:提取基础职位信息
↓
第二步:提取薪资和福利
↓
第三步:提取技能和任职要求
↓
第四步:执行风险或匹配判断
拆分的优点:
- 每次任务更加明确
- 可以针对失败步骤单独重试
- 不同阶段可以使用不同模型
- 容易统计每个字段的准确率
- 可以只运行当前业务真正需要的步骤
字段描述
一个好的字段描述,可以让开发者以及后续的维护者,能够非常清晰的知道具体含义,也可以让大模型更加容易知道具体的用途,一个好的字段描述通常应该包含以下信息:
- 来源要求
- 输出格式
- 单位
- 缺失值规则
- 禁止行为
比如下面这样的:
ini
#公司名称
company: str | None = Field(
default=None,
description=(
"招聘信息中明确出现的公司名称;"
"不得根据邮箱、职位或上下文推测;"
"原文未提供时返回null"
),
)
一套推荐的处理流程是这样的:
markdown
1. 使用小而明确的Schema
↓
2. Prompt说明业务语义和缺失值规则
↓
3. 优先使用with_structured_output()
↓
4. 执行Pydantic或JSON Schema校验
↓
5. 使用确定性代码做单位和格式转换
↓
6. 执行跨字段业务规则
↓
7. 验证证据是否存在于原文
↓
8. 根据错误类型决定修复或重试
↓
9. 高风险或冲突结果进入人工审核
↓
10. 记录模型、Schema版本、延迟和错误
六、Callback & Config
run与运行树
Run 是对一次实际运行过程的完整记录。它不仅表示"某段代码执行过",还描述了这次执行由谁触发、接收了什么输入、产生了什么输出,以及执行过程中是否出现异常。 它是langchian中,每次 Runnable 的执行,都可以视为产生了一次 Run ,run是一个执行对象,而不是Runnable 本身
一个 Run 通常包含以下信息:
- Run ID:本次运行的唯一标识。
- Parent Run ID:父级 Run 的标识,用于表示调用关系;顶层 Run 通常没有父级。
- Runnable 名称 :被执行组件的名称,例如
summary_prompt或primary_model。 - Run 类型 :组件类别,例如
chain、prompt、llm、tool或parser。 - 输入与输出:本次执行接收的数据和产生的结果。
- 开始与结束时间:用于计算耗时和定位性能问题。
- 错误信息:执行失败时记录的异常类型及相关信息。
- Tags:便于筛选、分类和检索的标签。
- Metadata:与业务或运行环境有关的附加信息,例如用户 ID、模型版本和请求来源。
前面也介绍过,一个最基本的chian是这样的prompt | model | parser,由于chain 的输出也是rannable结构的,后续还可以继续链式调用,或者并行调用,这就形成了一个调用链,而每一个chian的调用,起码都会产生一个run,这些run都有一个Parent Run ID,这就可以构成一个运行树,
callback
Callback 是 LangChain 提供的一套运行事件通知机制。
当 Chain、Chat Model、Tool 或 Retriever 开始执行、正常结束、发生错误,或者产生流式 Token 时,LangChain 会通知已经注册的 Callback Handler。
一次典型的模型调用可能经历下面的生命周期:
markdown
组件开始执行
↓
接收并处理输入
↓
调用模型生成内容
↓
持续产生流式 Token(可选)
↓
正常结束或抛出错误
Callback Handler 可以在这些阶段执行观察性操作 ,比如输出调试信息、记录run id 和父子调用关系、统计执行耗时、搜集token用量、记录错误等。
不同的组件会触发不同类型的Callback事件
Chain
当 RunnableSequence、RunnableLambda 或其他 Chain 类型组件执行时,可能触发:
on_chain_start:Chain 开始执行;on_chain_end:Chain 正常结束;on_chain_error:Chain 执行失败。
Chat Model事件
Chat Model 常见事件包括:
on_chat_model_start:聊天模型开始执行;on_llm_new_token:模型产生新的流式 Token 或文本片段;on_llm_end:模型正常结束;on_llm_error:模型调用失败。
Tool事件
Tool 执行时可能触发:
on_tool_start:开始调用工具;on_tool_end:工具正常返回;on_tool_error:工具执行失败。
Retriever 事件
Retriever 执行向量检索或文档检索时,可能触发:
on_retriever_start:开始检索;on_retriever_end:检索结束;on_retriever_error:检索失败。
Listener
Listener 和 Callback 都可以观察 LangChain 的运行过程,但它们关注的抽象层次不同。
- Callback 面向 LangChain 的事件系统,可以处理 Chain、模型、Tool、Retriever 和流式 Token 等多种事件。
- Listener 面向某个具体 Runnable,只关注它开始、结束和失败这三个生命周期阶段。
看下小案例
ini
workflow = prompt | model | parser
workflow.invoke(
data,
config={"callbacks": [handler]},
)
#或者 链式调用
observed_workflow = workflow.with_listeners(
on_start=on_start,
on_end=on_end,
on_error=on_error,
)
第一种handler 通常可以收到顶层 Workflow 及其内部 Prompt、Model、Parser 的相关事件 第二种的 with_listeners 主要观察 workflow 这个 Runnable 的整体生命周期,并不会自动变成内部所有子组件各自的 Listener ,其中on_start、on_end和on_error都是自己编写的监听方法
with_listeners并不会立即执行chian,也是返回了一个runnabel,需要和其他chain的执行一样:observed_chain.invoke(input_data)
RunnableConfig
RunnableConfig 表示一次 Runnable 调用的运行配置和执行上下文。
调用 Runnable 时,可以通过 config 参数传入
arduino
config: RunnableConfig = {
"run_name": "news_summary_workflow",
"tags": ["chapter-7", "news"],
"metadata": {
"request_id": "req-1001",
"source": "demo",
},
"callbacks": [handler],
"max_concurrency": 4,
}
result = chain.invoke(
input_data,
config=config,
)
RunnableConfig 常见字段包括:
run_name:本次 Run 的名称;tags:用于分类和筛选的字符串标签;metadata:结构化运行上下文;callbacks:本次运行使用的 Callback Handler;max_concurrency:批处理或并行步骤允许使用的最大并发数;recursion_limit:允许的递归调用深度;configurable:传递给可配置 Runnable 的运行时参数。
Configurable
一条 Workflow 的整体处理流程通常是稳定的,但其中部分参数或组件可能需要根据运行环境、请求类型和实验策略动态变化。 一种做法是定义不同的chain,然后根据条件来选择那个chain,还有一种做法是 Configurable 提供的, Workflow 结构保持不变,只在运行时选择允许变化的参数或组件
arduino
result = chain.invoke(
data,
config={
"configurable": {
"llm": "backup",
"summary_max_tokens": 500,
},
},
)
七、LangServe
在前面的章节中,我们已经可以使用 LangChain 构建一条完整的 Workflow:
ini
workflow = prompt | model | parser
但此时 Workflow 仍然只是一个 Python 对象,通常只能在当前进程中通过 invoke()、stream() 等方法调用。
真实应用往往还需要让 Web 前端、移动端、Java 服务、定时任务或其他团队访问这条 Workflow。因此,需要将 Runnable 包装成一个标准 API 服务。
LangServe 的核心作用就是:
将 LangChain Runnable 发布为基于 FastAPI 的 HTTP API,并保留调用、批处理、流式输出和运行配置等 Runnable 能力。
LangServe 是 LangChain 生态中用于发布 Runnable 的服务工具。它建立在 FastAPI 之上,可以将 Runnable 自动转换为一组 HTTP 接口。 然后通过FastApi将这个接口暴露出去,给到web端使用。
看下这个简单的例子:
python
import os
from pathlib import Path
from dotenv import load_dotenv
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from langserve import add_routes
load_dotenv(Path(__file__).resolve().parent.parent / ".ENV")
def required_env(name: str) -> str:
value = os.getenv(name)
if not value:
raise RuntimeError(f"Missing required environment variable: {name}")
return value
model = ChatOpenAI(
model=required_env("LLM_MODEL_ID"),
base_url=required_env("LLM_BASE_URL"),
api_key=required_env("LLM_API_KEY"),
temperature=0.2,
timeout=int(os.getenv("LLM_TIMEOUT", "60")),
)
prompt = ChatPromptTemplate.from_messages(
[
("system", "You are a concise and helpful assistant. Reply in Chinese."),
("human", "{message}"),
]
)
# Prompt | model | parser is itself a Runnable and can be exposed by LangServe.
chat_chain = prompt | model | StrOutputParser()
app = FastAPI(title="LangServe Runnable Demo", version="1.0.0")
# Adjust allow_origins in production to contain only the web application's origin.
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
add_routes(app, chat_chain, path="/chat")
if __name__ == "__main__":
import uvicorn
uvicorn.run("langserve_app:app", host="127.0.0.1", port=8000, reload=True)
启动后访问:
- Playground:
http://127.0.0.1:8000/chat/playground/ - Swagger:
http://127.0.0.1:8000/docs - Runnable:
POST http://127.0.0.1:8000/chat/invoke
然而比较常用的还是流失输出, 启用 streaming=True
ini
model = ChatOpenAI(
model=required_env("LLM_MODEL_ID"),
base_url=required_env("LLM_BASE_URL"),
api_key=required_env("LLM_API_KEY"),
temperature=0.2,
timeout=int(os.getenv("LLM_TIMEOUT", "60")),
streaming=True,
)
然后写个html调用这个post接口:
javascript
const response = await fetch("http://127.0.0.1:8000/chat/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input: { message: message.value } }),
signal: controller.signal
});
if (!response.ok || !response.body) {
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}
const reader = response.body
.pipeThrough(new TextDecoderStream())
.getReader();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += value.replaceAll("\r\n", "\n");
const frames = buffer.split("\n\n");
buffer = frames.pop() ?? "";
frames.forEach(handleSseFrame);
}
八、实战:故事结构化抽取器
在最后的实战环节,准备做一个故事结构化抽取器,就是输入一个文本,根据规则得到一个结构化的输出,其实已根据前面的介绍,这应该很简单,就定义好Schema,定义好Pydantic,然后确定好prompt,调用with_structured_output就可以得到结构化的输出了,但是这个项目的核心难点不在于输出的格式,而是信息抽取边界和信息的可信度。
Schema和Pydantic只能保证字段类型、值存在与否,并不能保证模型输出的文本是不是真的来自于原文,是不是模型自己瞎回答的,比如这样一段文本: 陈默穿着白大褂走进病房。 如果模型输出
json
{
"name": "陈默",
"occupation": "医生"
}
结构完全正确,Pydantic 校验也会通过 ,但是这个结论对不对,那就不一定了,穿白大褂的并不一定就是医生。
所以这有几个重要的边界需要确立下:
-
明确实施与合理推断的边界
"拿着听诊器"不等于"职业是医生"。如果项目只允许事实抽取,就必须禁止这种合理但未经证实的推断。
-
未知、确实和空集合的边界
json{ "occupation": null, "aliases": [] } #occupation: null 表示这个属性未知;aliases: [] 表示没有抽取到任何别名。 -
叙述事实与角色陈述的边界
张伟说:"凶手是李明。 这只能证明"张伟指控李明",不能直接把"李明是凶手"写进故事世界的确定事实。
-
事实与冲突事实的边界
如果前文说角色 25 岁,后文说角色 28 岁,程序不能随便挑一个。应该保留两条陈述,并标记为待解决冲突
-
任务同一性的边界
"林医生""林晚""她"可能是同一个人,也可能不是。错误合并会污染全部关系和事件;不合并则会产生重复人物。
-
显示因果与时间相邻的边界
"A 发生后,B 发生"只代表先后关系,并不一定代表 A 导致 B。模型很容易把时间顺序自动补成因果关系。
所以这个项目的核心点在于: 设计一套数据契约,让模型不仅"填对格式",还尽可能做到不越过原文证据的边界,做到原本不可见、不可控的幻觉问题,变成一套可以追踪、校验、报告和人工修复的工作流程。
项目结构
我们的项目最终会走一个这样的流程:
markdown
1. 文本导入
保存原文版本并分配 chunk_id
2. 声明抽取
提取实体、关系、事件、角色陈述和证据
3. 机械校验
检查 Schema、引用、ID、枚举和字段状态
4. 语义分析
进行人物消歧、冲突检测和因果分类
5. 风险分流
自动接受、人工审核、拒绝或重新抽取
6. StoryBible 构建
只合并已接受事实
7. 下游生成
大纲、剧本和分镜读取受控事实
8. 生成后检查
将生成内容重新抽取,与 StoryBible 对比
9. 人工决策
接受合理创作、修复幻觉、解决冲突
10. 版本发布
生成新的 StoryBible 版本并保留审计记录
然后就开始vibe coding了
最终的目录结构长这样:
markdown
story_bible_extractor/
├── __init__.py
├── __main__.py
├── cli.py
├── models.py
├── pipeline.py
├── prompt.py
├── validators.py
├── README.md
└── tests/
├── test_pipeline.py
└── test_validators.py
这里设计了两套数据,其中 candidate_bible 是模型抽取出来的候选结果,包含一些不确定的东西,比如尚未验证的人物、角色产生的陈述信息
-
这里定义了所有的数据结构,比如
Character、Relationship和StoryEvent等 -
定义模型的工作规则,值得贴下代码
iniPROMPT_VERSION = "story-extractor-v1" SYSTEM_PROMPT = """你是严格的故事事实抽取器,而不是故事创作者。 规则: 1. 只能提取输入文本明确表达的信息,禁止补全职业、年龄、动机、亲属关系或因果。 2. 每条 claim 必须附带一段从原文逐字复制、连续出现的 quote。 3. 叙述描写使用 narrative_fact;角色说法使用 character_statement,并填写 speaker; 只有无法避免的解释性结论才使用 inference。 4. 角色说"某人是凶手"只证明角色作出该陈述,不证明某人确实是凶手。 5. "A之后B发生"只表示顺序;原文没有明确因果词时,不填写 cause/result。 6. 无法确认是否为同一人的称谓不要合并;未知标量填 null,未知集合填空列表。 7. 原文出现互斥说法时全部保留为 claim,不要自行裁决。 8. claim id 使用 claim-001、claim-002 的稳定递增格式。 9. review_status 始终先输出 needs_review,最终状态由程序校验决定。 10. 每个人物、关系、事件都必须用 source_claim_ids 引用支持它的 claim;没有对应声明则不要输出该对象。 """ EXTRACTION_PROMPT = ChatPromptTemplate.from_messages( [ ("system", SYSTEM_PROMPT), ("human", "请抽取下面故事的结构和带证据声明:\n\n{story}"), ] ) -
这里负责确定性程序校验,比如引用是否存在原文, Claim ID 是否重复等
-
这里主要组装完整 LCEL 链
-
这里提供命令行入口,读取文件,创建模型,执行抽取,保存证书的 StoryBible ,保存完整的审计报告。
主要的数据模型有下面几种
-
Evidence,原文证据,
pythonclass Evidence(BaseModel): quote: str start: int | None = None end: int | None = None -
Claim,原子申明,
pythonclass Claim(BaseModel): id: str subject: str predicate: str value: str | int | bool | None kind: ClaimKind evidence: list[Evidence] speaker: str | None confidence: float review_status: ReviewStatus validation_errors: list[str] conflicts_with: list[str]这是最重要的数据模块,将一个结论拆成主体 + 属性/关系 + 值,比如林晚说:"陈默就是凶手
css{ "id": "claim-002", "subject": "陈默", "predicate": "被指控为", "value": "凶手", "kind": "character_statement", "speaker": "林晚", "evidence": [ { "quote": "林晚说:"陈默就是凶手。"" } ] }只是记录主体、状态,没有做任何推论
-
ClaimKind,申明类型
iniclass ClaimKind(str, Enum): NARRATIVE_FACT = "narrative_fact" #原文直接叙述的事实 CHARACTER_STATEMENT = "character_statement" #角色自己说的话 INFERENCE = "inference" #模型推断的结论 -
ReviewStatus,审核状态
iniclass ReviewStatus(str, Enum): AUTO_ACCEPTED = "auto_accepted" #证据存在,并且属于叙述事实 NEEDS_REVIEW = "needs_review" #可能合理,但必须人工确认 REJECTED = "rejected" #缺少证据,引用不存在或者技术问题 -
source_claim_ids ,结构与证据的连接
运行流程
再来梳理下整个项目的运行流程
定义一个story.txt,然后运行命令:
shell
python -m agent.langchain.story_bible_extractor `
>> agent/langchain/story_bible_extractor/story.txt `
>> --output output/story_bible.json `
>> --report output/extraction_report.json
首先去看下入口文件__main__.py,是这样:
css
from .cli import main
main()
然后再到cli.py,这才是真正的入口文件,在main方法中,会先解析 input、--output、--report,读取 story.txt,再接着执行report = extract_story(story, create_model()),这个就是agent工作的入口, 第一个参数story就是读取的文本,create_model是这样定义的:
ini
return ChatOpenAI(
model=os.environ["LLM_MODEL_ID"],
api_key=os.environ["LLM_API_KEY"],
base_url=os.environ["LLM_BASE_URL"],
temperature=0,
timeout=int(os.getenv("LLM_TIMEOUT", "90")),
)
会创建一个ChatOpenAI模型,而extract_story方法定义在pipeline.py中,根据文件名称就知道这里是创建LCEL 链的,在extract_story方法中,draft = build_chain(model).invoke({"story": story})开始创建chain,build_chain是这样定义的
ini
def build_chain(model: BaseChatModel):
structured_model = model.with_structured_output(
ExtractionDraft,
method="function_calling",
)
return EXTRACTION_PROMPT | structured_model
这里使用with_structured_output,利用模型原生的结构化输出能力,这样可靠性最高的。ExtractionDraft就是定义好的Pydantic,然后执行EXTRACTION_PROMPT | structured_model,EXTRACTION_PROMPT就是前面介绍的提示词,这就是一个完成的chain了。前面也介绍过,这个项目的核心要点不在于格式化输出,而是后续的各种边界情况的约束和冲突的检测与追踪,人工审核的干预,保证每一个结论的输出都有来源可追溯,同样的,这也是我们这个项目真正的工程目标,不是消灭模型的幻觉,而是让幻觉变得可见、可追踪、可拦截、可修正。
Pydantic 校验
这里在创建chain的时候,通过with_structured_output已经传入了ExtractionDraft,已经做了Pydantic校验,但是又做了第二次的校验:
scss
if not isinstance(draft, ExtractionDraft):
draft = ExtractionDraft.model_validate(draft)
这里我一开始也很纳闷,为啥要校验第二次,后来才了解到这里是 属于防御性兼容,不是正常流程中必需的第二次校验
证据校验
ini
claims = validate_claims(draft.claims, story)
validate_claims这个方法中会逐条检查,pos = source.find(evidence.quote),找到原文的引用
ini
evidence.start = pos
evidence.end = pos + len(evidence.quote)
如果找不到
go
errors.append("quote not found in source")
claim.review_status = ReviewStatus.REJECTED
在最后
ini
if errors:
claim.review_status = ReviewStatus.REJECTED
elif claim.kind == ClaimKind.NARRATIVE_FACT:
claim.review_status = ReviewStatus.AUTO_ACCEPTED
else:
claim.review_status = ReviewStatus.NEEDS_REVIEW
做一个申明分类,保证 角色陈述和推断不会直接发
冲突检测
ini
conflicts = detect_conflicts(claims)
这里并不是调用大模型进行语义判断,而是使用 Python 中确定性的规则检测冲突
就是当subject 和 predicate 相同,但 value 出现两个或更多不同值,就认为存在冲突。
ini
groups: dict[tuple[str, str], list[Claim]] = defaultdict(list)
for claim in claims:
if claim.review_status != ReviewStatus.REJECTED and claim.value is not None:
groups[(claim.subject.strip(), claim.predicate.strip())].append(claim)
conflicts: list[Conflict] = []
for (subject, predicate), group in groups.items():
values = {str(item.value).strip() for item in group}
if len(values) < 2:
continue
ids = [item.id for item in group]
conflicts.append(
Conflict(
subject=subject,
predicate=predicate,
claim_ids=ids,
values=sorted(values),
)
)
for claim in group:
claim.conflicts_with = [item for item in ids if item != claim.id]
claim.review_status = ReviewStatus.NEEDS_REVIEW
return conflicts
核心设计
这个项目的数据并没有只保存一个 StoryBible ,而是区分候选数据和正式数据,
-
candidate_bible包含一些尚未验证过的人物、角色陈述产生的信息、推断内筒
-
story_biblestory_bible是经过程序验证后发布的数据。
ini
candidate_bible = StoryBible(
worldview=draft.worldview,
characters=draft.characters,
relationships=draft.relationships,
events=draft.events,
unresolved_conflicts=draft.unresolved_conflicts,
)
这里就是模型生成的全部内容,当数据有详细的来源,没有冲突,就会被放到story_bible 里面,然后 ExtractionReport 将整个数据的处理结果打包到一起,生成一个json
ini
ExtractionReport(
story_bible=bible, # 可以正式发布的内容
candidate_bible=candidate_bible, # 模型生成的全部候选内容
claims=claims, # 所有声明及其审核状态
conflicts=conflicts, # 检测到的结构化冲突
source_sha256=..., # 原文指纹
prompt_version=..., # Prompt 版本
publication_warnings=warnings, # 未发布内容的警告
)
而最终可以发布的story_bible,一定是有原文证据、属于叙事事实、没有冲突的内容