为什么 FastAPI 和 LangChain 天然适配
当一个团队决定把大模型能力封装成 API 服务对外提供时,选型通常会在两个方向上纠结:用什么 Web 框架,以及用什么 LLM 编排框架。如果你选了 Python 技术栈,FastAPI 和 LangChain 几乎是目前最主流的组合——这不是偶然。
FastAPI 从设计之初就是异步优先的,基于 ASGI 标准原生支持 async/await,天然适合处理 I/O 密集型的请求场景。而大模型应用的核心特征恰恰就是高延迟 I/O:一次 LLM 调动可能需要 2 到 10 秒,加上 RAG 检索、向量数据库查询,整个请求链路可能更长。FastAPI 的异步模型意味着在等待模型响应期间,事件循环可以继续处理其他请求,而不是被阻塞住。
LangChain 则提供了 LCEL(LangChain Expression Language)这一声明式编排能力。所有 LCEL 链都实现了 Runnable 接口,自带 invoke、stream、astream、astream_events 等方法。这意味着你不需要自己手写流式输出的逻辑——LangChain 在链式调用的每一层都内置了流式支持,只要你的链是用 | 操作符拼起来的,流式传输就是自动的。把这套能力接到 FastAPI 的 StreamingResponse 上,就是一套非常顺滑的端到端流式方案。
但组合好用不代表组合容易用好。真正落地时,异步上下文的混用、并发控制、模型 API 的不稳定性、版本碎片化,每一个都能让你在生产环境里栽跟头。下面我按照从架构到细节的顺序,把关键问题逐一拆开来说。
项目结构该怎么组织
很多教程喜欢把 FastAPI 路由和 LangChain 逻辑全部塞进一个 main.py 里,这在 demo 阶段没问题,但一旦服务要长期维护、链路要复用、团队有多人协作,这种写法就会很快变成一团乱麻。
更合理的做法是把 API 层和链路编排层拆开。API 层只负责请求校验、路由分发和响应格式,LangChain 逻辑封装成独立的模块,按业务领域组织。一个典型的项目结构大致长这样:
ai-service/
├── app/
│ ├── main.py # FastAPI 应用入口
│ ├── api/
│ │ ├── routes/
│ │ │ ├── chat.py # 对话相关路由
│ │ │ └── rag.py # RAG 问答路由
│ │ └── deps.py # 依赖注入(模型实例、向量库等)
│ ├── chains/
│ │ ├── chat_chain.py # 基础对话链
│ │ └── rag_chain.py # RAG 检索增强链
│ ├── models/
│ │ └── schemas.py # Pydantic 请求/响应模型
│ └── core/
│ ├── config.py # 配置管理
│ └── middleware.py # 中间件(限流、日志等)
├── requirements.txt
└── Dockerfile
这种结构的好处在于,当你要替换模型供应商、调整 prompt 策略或者增加新的业务链路时,改动范围是可控的。API 层不需要知道链路内部是怎么编排的,链路层也不需要关心请求是从哪个端点进来的。
从最简单的链开始:LCEL 到底解决了什么
如果你还停留在用 LLMChain 这类旧 API 的阶段,建议尽快迁移到 LCEL。LCEL 的核心价值不在于语法好看,而在于它统一了所有组件的接口。一个用 | 拼接的链,自动获得了流式、批处理、异步执行和中间步骤追踪能力——这些在旧 API 里都需要你手动实现。
先看一个最小化的对话链:
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_openai import ChatOpenAI
prompt = ChatPromptTemplate.from_messages([
("system", "你是一个专业的技术问答助手,回答要简洁准确。"),
("human", "{question}"),
])
model = ChatOpenAI(model="gpt-4o", temperature=0.3)
parser = StrOutputParser()
chat_chain = prompt | model | parser
三行代码完成了一个完整的对话链。但这里有一个容易被忽略的细节:ChatOpenAI 实例应该在哪里创建?很多教程直接在路由函数里 new 一个,这意味着每次请求都会创建一个新的模型客户端实例。正确的做法是使用单例模式,通过 FastAPI 的依赖注入来复用连接:
from functools import lru_cache
from fastapi import Depends
@lru_cache()
def get_chat_model() -> ChatOpenAI:
return ChatOpenAI(model="gpt-4o", temperature=0.3)
@lru_cache()
def get_chat_chain() -> Runnable:
model = get_chat_model()
prompt = ChatPromptTemplate.from_messages([
("system", "你是一个专业的技术问答助手。"),
("human", "{question}"),
])
return prompt | model | StrOutputParser()
@app.post("/chat")
async def chat(req: ChatRequest, chain: Runnable = Depends(get_chat_chain)):
result = await chain.ainvoke({"question": req.question})
return {"answer": result}
这里用了 ainvoke 而不是 invoke。在 FastAPI 的异步路由里,必须用 LangChain 的异步方法,否则模型调用会阻塞整个事件循环。这是后面会详细讨论的一个核心陷阱。
流式响应:把 token 体验做到位
非流式的 API 调用在体验上有一个致命问题:用户发出请求后,要等模型完整生成才能看到任何输出。对于一次 5 秒的 LLM 调用,这 5 秒内用户面对的是一个空白页面或转圈动画。流式响应的价值在于,模型每生成一个 token 就立刻推送给前端,让用户感知到”正在回答”,而不是”卡死了”。
FastAPI 的 StreamingResponse 配合 LangChain 的 astream 方法,实现起来非常直接:
from fastapi.responses import StreamingResponse
@app.post("/chat/stream")
async def chat_stream(req: ChatRequest, chain: Runnable = Depends(get_chat_chain)):
async def generate():
async for chunk in chain.astream({"question": req.question}):
yield f"data: {chunk}
"
yield "data: [DONE]
"
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
)
这里有一个细节值得注意:SSE(Server-Sent Events)的格式要求每个数据块以 data: 开头,以
结尾。前端用 EventSource API 就可以直接消费。最后的 [DONE] 标记告诉前端流已经结束,避免前端一直等待。
但如果你构建的是 RAG 链,需求会更复杂。你不只希望流式输出最终答案,可能还希望前端能看到检索到的参考文档。这时候 astream 只能给你最终输出,中间步骤的信息拿不到。LangChain 提供了 astream_events 来解决这个问题:
async def generate():
async for event in chain.astream_events({"question": req.question}, version="v2"):
kind = event["event"]
if kind == "on_retriever_end":
docs = event["data"].get("output", {}).get("documents", [])
for doc in docs:
yield f"event: source
data: {doc.metadata}
"
elif kind == "on_chat_model_stream":
token = event["data"].get("chunk", "")
if token:
yield f"data: {token}
"
yield "data: [DONE]
"
这个写法可以同时推送检索到的源文档和逐 token 的模型输出。前端通过不同的事件类型区分,先显示参考来源,再逐步展示答案。这种体验在客服系统、文档问答等场景下非常实用。
不过要注意,astream_events 的版本参数目前推荐用 "v2",旧版本已经被标记为不推荐使用。而且这个方法的性能开销比 astream 大一些,因为它需要追踪链中每个组件的事件。如果你的链路比较长(比如多轮 Agent 调用),建议做一下性能测试再决定是否启用。
生产环境里的四个真实陷阱
陷阱一:同步调用混入异步上下文
这是我在 code review 中见过最多的问题。FastAPI 路由函数声明为 async def,但链路内部调用了同步方法——比如用了 invoke 而不是 ainvoke,或者在链中嵌入了一段同步的数据库查询。
后果是什么呢?invoke 是一个同步阻塞调用,它会占用当前线程直到模型返回。在 ASGI 的事件循环里,这意味着整个 worker 线程被阻塞,其他请求只能排队等待。在高并发场景下,这会导致响应时间急剧上升,甚至触发上游网关的超时。
解决这个问题有两个方向。如果 LangChain 组件提供了异步方法,就直接用 ainvoke / astream。如果有些第三方组件确实只有同步接口,用 asyncio.to_thread() 把它丢到线程池里执行,至少不阻塞事件循环:
import asyncio
# 不要这样做:在 async 路由里直接同步调用
result = some_sync_function(input_data)
# 应该这样做:交给线程池执行
result = await asyncio.to_thread(some_sync_function, input_data)
需要理解的是,FastAPI 有两种路由模式:async def 和普通的 def。如果你声明的是普通 def,FastAPI 会自动把它放到线程池里执行,不会阻塞事件循环。但如果路由是 async def,里面的同步调用就会直接阻塞。所以规则很简单:要么全异步,要么全同步,不要混着来。
陷阱二:大模型 API 的不稳定性和限流
当你把服务部署到生产环境后,真正让你头疼的可能不是代码逻辑,而是模型 API 本身的可靠性。OpenAI、Anthropic、国内各模型供应商的 API,在高并发下经常出现 429 限流、502 网关超时、连接重置等问题。
很多团队一开始没有做任何重试和超时处理,线上直接裸跑,结果一遇到模型 API 波动,整个服务就跟着挂了。LangChain 本身提供了 with_retry() 方法,可以给链路加上自动重试:
from langchain_core.runnables import RunnableLambda
chain_with_retry = chain.with_retry(
stop_after_attempt=3,
wait_exponential_jitter=True,
retry_if_exception_type=(TimeoutError, ConnectionError),
)
但光有重试还不够。在并发层面,如果同时有 100 个请求打进来,全部去调模型 API,大概率会直接触发供应商的限流。这时候需要在 API 层做并发控制。一个实用的做法是用信号量限制同时进行的模型调用数量:
import asyncio
MAX_CONCURRENT_LLM_CALLS = 5
semaphore = asyncio.Semaphore(MAX_CONCURRENT_LLM_CALLS)
@app.post("/chat")
async def chat(req: ChatRequest, chain: Runnable = Depends(get_chat_chain)):
async with semaphore:
try:
result = await asyncio.wait_for(
chain.ainvoke({"question": req.question}),
timeout=30,
)
return {"answer": result}
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="模型响应超时")
这套组合拳——信号量限并发、wait_for 限时、with_retry 重试——能覆盖大部分模型 API 不稳定的场景。
陷阱三:LangChain 版本碎片化
LangChain 的更新频率非常高,几乎每周都有新版本。但它的 API 变动也比较频繁,尤其是从 langchain 单包拆分成 langchain-core、langchain-community、langchain-openai 等多个包之后,版本之间的兼容性问题非常多。
一个典型的场景:你在本地开发时 pip install langchain 默认装了最新版,一切正常。但部署到生产服务器后,由于构建时间不同,可能拉到了不同的次版本,结果某个 import 路径变了,服务直接启动失败。
解决方案很直接但必须严格执行:在 requirements.txt 中锁定所有 LangChain 相关包的确切版本,不要用 >= 这种范围版本号。同时建议把 langchain-core、langchain-openai 等核心依赖也一并锁定,因为它们之间的版本配合关系并不总是透明的。
# requirements.txt - 不要这样写
langchain
langchain-openai
chromadb
# 应该这样写,精确到补丁版本
langchain==0.3.7
langchain-core==0.3.15
langchain-openai==0.2.9
langchain-community==0.3.7
chromadb==0.5.20
另外,如果你还在用 from langchain.chains import LLMChain 这种旧 import 路径,趁早迁移。新版 LangChain 已经把很多旧 API 移到了 langchain_community 包下,而且标记为不推荐使用。持续使用旧 API 只会让未来的迁移成本越来越高。
陷阱四:流式响应和反向代理的兼容性
这个坑比较隐蔽。你的流式接口在本地 uvicorn 直接跑没问题,但部署到 Nginx 后面后,前端收不到流式数据——所有数据在模型生成完毕后才一次性返回。
原因通常是 Nginx 默认开启了 buffer。SSE 流式响应需要关闭 Nginx 的 proxy buffer,否则 Nginx 会把后端的小数据块攒成大块再发给客户端。解决方法是在 Nginx 配置中加上:
location /api/ {
proxy_pass http://backend;
proxy_buffering off;
proxy_cache off;
proxy_set_header Connection "";
proxy_http_version 1.1;
chunked_transfer_encoding on;
}
如果你用的是云厂商的 API 网关(比如阿里云 API 网关、AWS API Gateway),也需要检查是否支持 SSE 流式透传。部分网关产品对流式响应的支持不够好,可能需要换成直连或者换用专门的流式网关方案。
方案对比:流式 vs 非流式,同步 vs 异步
在实际项目中,不同的业务场景对响应模式和调用方式的需求是不一样的。下面这张表总结了四种常见组合的适用场景和取舍:
| 方案组合 | 用户体验 | 实现复杂度 | 适用场景 | 主要风险 |
|---|---|---|---|---|
| 异步路由 + 非流式输出 | 需等待完整响应 | 低 | 内部工具、批处理任务 | 长延迟导致超时 |
| 异步路由 + 流式输出 | 逐 token 返回,体验好 | 中 | 面向终端用户的对话、文档问答 | 反向代理 buffer 问题 |
| 同步路由 + 非流式输出 | 需等待完整响应 | 低 | 简单脚本、原型验证 | 并发性能差 |
| 同步路由 + 流式输出 | 逐 token 返回 | 高 | 不推荐用于生产 | 阻塞线程、并发受限 |
对于大多数面向用户的服务,”异步路由 + 流式输出”是推荐的生产方案。它既不阻塞事件循环,又能给用户即时的反馈。唯一的额外成本是需要处理 SSE 的前端消费逻辑和反向代理配置,但这些是一次性的工程投入。
如果你的服务是给内部系统调用的(比如批处理任务、后台数据清洗),非流式的异步调用更简单,不需要处理流式数据的解析,直接 await chain.ainvoke() 拿完整结果就行。
RAG 链路中的架构决策
当你在 FastAPI 中构建 RAG 服务时,一个关键决策是向量数据库的选择和检索策略的设计。这不是本文的重点,但有几个和 API 服务直接相关的工程问题值得提一下。
第一个是向量库的连接管理。如果你用的是 Chroma、FAISS 这类本地嵌入式的向量库,它是在进程内运行的,不需要额外的连接池管理。但如果你用的是 Qdrant、Milvus、Pinecone 这类需要网络连接的向量数据库,就必须像对待数据库连接一样管理客户端实例——用单例模式复用连接,不要每个请求都新建客户端。
第二个是检索链的编排方式。一个典型的 RAG 链用 LCEL 可以这样写:
from langchain_core.runnables import RunnablePassthrough
def format_docs(docs):
return "
".join(doc.page_content for doc in docs)
rag_chain = (
{"context": retriever | format_docs, "question": RunnablePassthrough()}
| prompt
| model
| StrOutputParser()
)
这里的 RunnablePassthrough() 起到的作用是把用户原始输入透传到 prompt 模板,同时 retriever 并行地去检索相关文档。这个链路天然支持流式——当你调用 astream 时,检索完成后模型开始逐 token 输出,整个过程不需要你额外写任何流式控制逻辑。
但如果你想让用户也能看到检索到了哪些文档,就需要用 astream_events,前面已经展示过这种写法。取舍在于:astream 简单高效,astream_events 功能更全但开销更大。如果前端不需要中间步骤信息,就用 astream;如果需要展示检索来源或链路中间状态,就用 astream_events。
从开发到上线的实践建议
最后整理一组实战建议,帮助你避开从开发到上线过程中的高频问题:
- 模型客户端必须复用:无论用哪个模型供应商,客户端实例都应该是全局单例。每次请求新建客户端会导致连接池无法复用,在高并发下会耗尽 TCP 连接。
- 超时和重试缺一不可:模型 API 调用必须设置超时,建议 15-30 秒。同时配合重试机制,重试次数控制在 2-3 次,避免雪崩。重试要带指数退避,防止短时间内集中重试压垮上游。
- 关注内存占用:如果你在链路中使用了 ConversationBufferMemory 之类的对话记忆组件,长对话场景下内存会持续增长。建议定期清理或使用摘要式记忆来控制上下文长度。
- 日志要记录链路全貌:线上排查问题时,光知道”返回了 500″是不够的。建议用 LangChain 的 callback 机制记录每次调用的输入、输出、耗时和中间步骤,配合结构化日志方便后续检索。
- 做好降级预案:模型 API 挂了不代表你的服务也得挂。可以在模型调用失败时返回缓存结果、降级到规则引擎或简单回复,而不是直接 500。这需要在前端协议设计时就预留 error 字段的降级语义。
- Docker 镜像要分层优化:LangChain + 依赖的完整镜像可能超过 2GB。用多阶段构建,把构建依赖和运行时依赖分开,最终镜像只保留运行时所需的包。这对部署速度和容器启动时间都有直接影响。
有一个容易忽视的工程细节:如果你的服务需要支持多轮对话,建议把对话状态管理放到外部存储(Redis、数据库),而不是依赖 LangChain 的内存组件。原因是内存组件是进程内的,服务重启后状态就丢了;如果有多实例部署,不同实例之间的对话状态也无法共享。把状态外置后,FastAPI 层只负责无状态的请求处理,水平扩展会简单很多。
别急着上 Agent,先把链路跑稳
很多团队在搭建 AI API 服务时,一上来就想做 Agent、做多工具编排、做复杂推理流程。但生产环境的现实是,一个简单的 RAG 链如果能稳定地流式输出、可靠地处理超时重试、在高并发下不崩,就已经比市面上大部分 AI 服务靠谱了。
FastAPI + LangChain 这套组合的真正价值,不在于它能做多复杂的链路,而在于它把异步处理、流式输出、链式编排这三件事统一到了一个清晰的框架里。当你把基础的对话和 RAG 链路跑稳之后,再逐步引入更复杂的 Agent 逻辑,才是更可靠的演进路径。
记住一点:AI 应用的工程复杂度,往往不在模型能力本身,而在于你怎么把一个不稳定的、高延迟的、有状态的外部依赖,封装成一个对调用方而言稳定可靠的 API 服务。把这个核心问题解决好,后面无论模型怎么换、链路怎么变,你的架构底盘都是稳的。
原创文章,作者:,如若转载,请注明出处:https://fudengji.cn/article/290/