在本地模型这条路上折腾了这么久,我一直觉得LangChain加Ollama的组合是被低估的一套方案。很多人以为LangChain只是用来对接GPT这类云端大模型的,实际上它和Ollama配合,能把本地模型的能力迅速包装成一个标准化API服务,供团队或自己的其他项目调用。这篇内容就是围绕"如何把LangChain和Ollama封装成一套可用的API接口"展开的实战记录,包含完整代码、踩坑过程和排查思路,适合正在做本地大模型应用开发、想给内部系统接上私有模型能力的读者参考。
我当时的需求很明确:公司内部有个文档问答系统,需要接一个本地部署的模型服务,但又不想让业务方直接面对Ollama那条既原始又不稳定的调用链路,而且业务方需要的是OpenAI风格的接口,方便前端和业务后端无缝切换。所以我就用LangChain做中间编排层,Ollama做推理引擎,外面再包一层FastAPI,做成了一套统一API网关。整个过程下来,踩了不少坑,尤其是那个高频出现的400上下文长度超限报错,整整折磨了我两天。这次把完整思路和代码都整理出来,希望能帮你少走弯路。
1. 为什么要把LangChain和Ollama封装成API服务
1.1 这组技术栈到底解决了什么问题
先说结论:这套组合适合"模型私有化部署 + 应用层统一接入"这一类场景。
Ollama负责把你下载好的开源模型跑成本地服务,默认监听11434端口,提供一套自己的HTTP接口。但问题在于,Ollama的接口偏向底层,没有复杂的会话管理、工具调用、文档检索这些能力,而且模型切换、参数控制都比较裸。如果你只是自己在终端里curl两下,那完全没问题,可一旦要面向业务系统,就需要在模型之上加一层业务逻辑。
LangChain恰好补上这一层。它把"模型调用""提示词管理""文档切分""向量检索""工具调用"这些能力抽象成标准组件,我可以在LangChain里把Ollama的模型实例封装进来,再往外提供统一接口。至于最外层为什么还要包一个FastAPI,理由更直接——LangChain本身不是Web服务框架,它管的是AI能力编排,不负责HTTP路由。要让外部系统真正用起来,就需要一个能暴露HTTP端点、处理并发、做参数校验的服务层。
所以这套架构拆开看就是三层:Ollama是引擎,LangChain是大脑,FastAPI是门面。
1.2 从"本地跑模型"到"统一API"的差距
直接用Ollama的/API/chat接口,和通过LangChain封装后对外提供接口,差距在哪里?我用一次实际调用来说明。
Ollama原生接口长这样,你需要自己拼请求体,自己处理响应,而且它不做会话历史管理:
bash复制curl http://localhost:11434/api/chat -d '{
"model": "qwen2.5:7b",
"messages": [
{"role": "user", "content": "你好"}
]
}'
这个接口能跑,但有几个明显痛点。第一,每个调用方都得知道Ollama的接口规范,业务方还得关心模型名称、请求体格式这些细节。第二,Ollama默认不保留对话上下文,多轮对话需要调用方自己把历史消息全部传进来,很啰嗦。第三,没有鉴权、限流这些生产环境必备的能力,裸奔状态。
而封装之后,业务方只需要发送一个标准的POST请求,带上问题就行,会话管理、历史拼接、模型路由全都在服务端完成。从业务方的视角看,他根本不用关心背后跑的是Ollama还是别的什么引擎。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与基础搭建
2.1 Ollama本地部署的关键细节
Ollama的安装本身不复杂,去官网下载对应平台的安装包即可,Windows和macOS都有图形化安装程序,Linux用官方脚本一行命令搞定。但有几个细节操作不当会特别难受。
第一个是模型下载路径。Ollama默认会把模型放在C盘用户目录下,一个7B模型的量化版大概4到5GB,如果你装了好几个模型,C盘很容易爆。建议提前把模型路径迁移到其他盘。
Linux下设置环境变量:
bash复制export OLLAMA_MODELS=/data/ollama/models
Windows下直接设置系统环境变量OLLAMA_MODELS,指向你想要存放模型的目录。
第二个是监听地址。默认只监听127.0.0.1,如果模型服务部署在一台服务器上,而你的业务服务在另一台机器,就需要让Ollama监听所有网卡:
bash复制export OLLAMA_HOST=0.0.0.0
第三个是模型下载慢的问题。Ollama默认从官方仓库拉取模型,国内网络环境下经常几百KB每秒,甚至直接超时。我实测下来,可以通过设置镜像地址来加速,比如配置OLLAMA_HOST的同时,给Ollama配置国内可用的镜像源。具体做法是在启动Ollama之前设置环境变量OLLAMA_BASE_URL指向一个可用的镜像站点,然后重启Ollama服务再拉模型。你如果遇到下载太慢的情况,优先检查这步。
我是直接用ollama pull qwen2.5:7b拉取模型,装好之后先跑通一次原生接口,确认本地推理没问题,再进入下一步。这一步千万别跳,如果Ollama本身都没配好,后面LangChain排查起来会多一层干扰。
2.2 LangChain环境配置与版本选择
LangChain的版本迭代很快,不同版本之间的API差异挺大。我写这篇内容时用的是LangChain 0.2.x版本线,langchain-ollama集成包已经拆分出来单独维护了。
创建虚拟环境并安装依赖:
bash复制python -m venv llm-api-env
source llm-api-env/bin/activate
pip install langchain
pip install langchain-ollama
pip install fastapi
pip install uvicorn
pip install pydantic
这里特意说明一下为什么用langchain-ollama而不是老的langchain_community.llms import Ollama。LangChain 0.2之后,官方把主流模型的集成逐渐收拢到独立的langchain-{provider}包中,langchain-ollama是官方维护的集成包,API设计上更贴合新版本,也不容易出现兼容性告警。虽然社区版那个类目前还能用,但既然官方已经给了标准解,就没必要在新项目里继续用旧接口。
安装完成后,快速验证一下LangChain能否正常调用本地模型:
python复制from langchain_ollama import ChatOllama
llm = ChatOllama(
model="qwen2.5:7b",
base_url="http://localhost:11434",
temperature=0.7
)
response = llm.invoke("用一句话介绍你自己")
print(response.content)
如果这里能正常输出,说明Ollama、LangChain、网络三层都通了,接下来就可以着手封装。
3. API封装设计与核心代码实现
3.1 接口整体设计与路由规划
在写代码之前,我先设计了接口规范。做API封装最重要的一件事,就是接口形式要贴近主流约定,方便调用方无痛接入。现在业界最通用的就是OpenAI风格接口,/api/chat带一个messages数组参数。
我设计了两个核心端点:
POST /api/chat:普通对话,传入messages消息数组,返回完整回复。POST /api/chat/stream:流式对话,基于SSE(Server-Sent Events)逐字返回内容,适合前端打字机效果。GET /api/models:查询当前Ollama已经部署了哪些模型,方便管理端做模型列表展示。
请求体用Pydantic定义,这样做的好处是参数校验自动完成,调用方传错类型或缺失字段时,FastAPI会直接返回400并给出明确的错误说明。
python复制from pydantic import BaseModel
from typing import List, Dict, Optional
class ChatMessage(BaseModel):
role: str
content: str
class ChatRequest(BaseModel):
model: str = "qwen2.5:7b"
messages: List[ChatMessage]
temperature: Optional[float] = 0.7
max_tokens: Optional[int] = 2048
stream: Optional[bool] = False
class ChatResponse(BaseModel):
code: int = 0
message: str = "success"
data: Dict
3.2 核心封装代码完整实现
接下来是核心的封装逻辑。这里我做了一个LLMService类,把LangChain的模型调用、历史会话拼接、异常处理全部收拢到类内部,外部只暴露一个chat方法。这样设计的好处是,将来如果要从Ollama切换到其他模型后端,只需要改LLMService内部的实现,API层完全不用动。
python复制from langchain_ollama import ChatOllama
from langchain_core.messages import HumanMessage, AIMessage, SystemMessage
from typing import List, Dict
import logging
logger = logging.getLogger(__name__)
class LLMService:
def __init__(self, base_url: str = "http://localhost:11434", model: str = "qwen2.5:7b"):
self.base_url = base_url
self.model = model
self.llm = self._init_llm()
def _init_llm(self) -> ChatOllama:
return ChatOllama(
model=self.model,
base_url=self.base_url,
temperature=0.7,
num_predict=2048,
top_k=40,
top_p=0.9
)
def _convert_messages(self, messages: List[Dict]):
"""将API请求中的消息格式转换为LangChain的消息对象"""
converted = []
for msg in messages:
role = msg.get("role", "user")
content = msg.get("content", "")
if role == "system":
converted.append(SystemMessage(content=content))
elif role == "assistant":
converted.append(AIMessage(content=content))
else:
converted.append(HumanMessage(content=content))
return converted
def chat(self, messages: List[Dict], model: str = None, temperature: float = None):
"""非流式对话,返回完整结果"""
try:
if model and model != self.model:
self.model = model
self.llm = self._init_llm()
if temperature is not None:
self.llm.temperature = temperature
langchain_messages = self._convert_messages(messages)
response = self.llm.invoke(langchain_messages)
return {
"reply": response.content,
"model": self.model,
"usage": {
"prompt_tokens": response.usage_metadata.get("input_tokens", 0) if response.usage_metadata else 0,
"completion_tokens": response.usage_metadata.get("output_tokens", 0) if response.usage_metadata else 0,
"total_tokens": response.usage_metadata.get("total_tokens", 0) if response.usage_metadata else 0
}
}
except Exception as e:
logger.error(f"LLM invoke failed: {e}", exc_info=True)
raise RuntimeError(f"模型调用失败: {str(e)}")
注意上面代码里的num_predict参数,它直接对应Ollama的num_predict,控制生成的最大token数。这里有个很容易踩的坑:num_predict太小,长文本生成会被截断;num_predict太大,又可能触发Ollama的上下文长度限制报错。我刚开始封装的时候没意识到这个参数默认值和上下文窗口之间的关系,导致输出经常莫名其妙地中断。后来统一设置为2048,配合7B模型的默认上下文窗口,稳定了很多。
3.3 流式输出实现与SSE协议对接
流式输出是API封装里比较能提升体验的部分。前端做打字机效果,或者在做流式对话机器人时,都需要后端逐字返回内容。
LangChain的stream方法返回一个生成器,每次产生一个消息块。FastAPI里可以用StreamingResponse把这个生成器包装成SSE流返回给客户端。
python复制from fastapi.responses import StreamingResponse
import json
class LLMService:
# ... 省略上面的代码 ...
def stream_chat(self, messages: List[Dict], model: str = None):
"""流式对话,逐块返回内容"""
try:
if model and model != self.model:
self.model = model
self.llm = self._init_llm()
langchain_messages = self._convert_messages(messages)
def generate():
for chunk in self.llm.stream(langchain_messages):
delta = chunk.content
yield f"data: {json.dumps({'delta': delta}, ensure_ascii=False)}\n\n"
yield f"data: [DONE]\n\n"
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no"
}
)
except Exception as e:
logger.error(f"LLM stream failed: {e}", exc_info=True)
raise RuntimeError(f"模型流式调用失败: {str(e)}")
这里有个容易忽略的细节:X-Accel-Buffering: no这个响应头。如果你在Nginx后面部署API服务,Nginx默认会缓冲响应内容,导致前端看到的不是逐字输出,而是等全部生成完一次性返回。加了这个响应头就能通知Nginx不要缓冲。如果是直连服务没有经过Nginx,这个头也没副作用,建议默认加上。
FastAPI主应用中的路由代码如下:
python复制from fastapi import FastAPI, HTTPException
from contextlib import asynccontextmanager
llm_service = LLMService()
app = FastAPI(title="Local LLM API Gateway")
@app.get("/api/models")
async def list_models():
# 查询Ollama已安装的模型
import requests
try:
resp = requests.get("http://localhost:11434/api/tags", timeout=5)
models = [item["name"] for item in resp.json().get("models", [])]
return {"code": 0, "data": {"models": models}}
except Exception as e:
raise HTTPException(status_code=500, detail=f"查询模型列表失败: {str(e)}")
@app.post("/api/chat", response_model=ChatResponse)
async def chat(request: ChatRequest):
try:
result = llm_service.chat(
messages=[m.model_dump() for m in request.messages],
model=request.model,
temperature=request.temperature
)
return ChatResponse(data=result)
except RuntimeError as e:
raise HTTPException(status_code=502, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"服务器内部错误: {str(e)}")
@app.post("/api/chat/stream")
async def chat_stream(request: ChatRequest):
try:
return llm_service.stream_chat(
messages=[m.model_dump() for m in request.messages],
model=request.model
)
except RuntimeError as e:
raise HTTPException(status_code=502, detail=str(e))
启动服务:
bash复制uvicorn main:app --host 0.0.0.0 --port 8000
这样一套可用的API封装就算完成了。我用postman实际测试过,普通对话接口响应时间在1到3秒左右(取决于模型大小和机器性能),流式接口首字返回大概在300到500毫秒,体感上还是挺流畅的。
4. 常见错误与排查技巧实录
4.1 高频400错误:最大上下文长度超限
这个报错我在实践过程中遇到得最多,原话是:
text复制api error: 400 this model's maximum context length is 1048576 tokens. However, you requested 1050000 tokens
注意这个数字,1048576是2的20次方,也就是1M token。Ollama在较新版本中给部分模型默认分配了很大的上下文窗口,但实际上你根本没有传那么多内容,为什么会触发这个报错?
我第一次遇到这个问题时也很懵,排查了很久。后来发现根源在于num_predict参数和上下文窗口的配置关系。Ollama中,num_ctx代表上下文窗口大小,num_predict代表生成的最大token数。当num_ctx没被显式设置时,Ollama会使用模型配置的默认值。但LangChain的ChatOllama在初始化时,如果传了num_predict,它在某些版本里会把num_predict + 输入token数作为总请求量发给Ollama,一旦这个总和超过模型最大上下文限制,就会返回400。
解决方式有两个:
第一,显式设置num_ctx参数,把它控制在模型支持的范围内:
python复制self.llm = ChatOllama(
model=self.model,
base_url=self.base_url,
temperature=0.7,
num_ctx=8192, # 限制上下文窗口
num_predict=2048 # 限制生成长度
)
第二,在调用前检查输入的历史消息总长度,如果接近上下文上限,就做截断或清理,只保留最近几轮对话:
python复制def _trim_messages(self, messages: List[Dict], max_chars: int = 8000):
"""简单粗暴地保留最近N字符内的消息"""
total_chars = sum(len(m["content"]) for m in messages)
if total_chars <= max_chars:
return messages
# 保留第一条system消息和最近的消息
system_msgs = [m for m in messages if m["role"] == "system"]
other_msgs = [m for m in messages if m["role"] != "system"]
trimmed = []
current_len = sum(len(m["content"]) for m in system_msgs)
for m in reversed(other_msgs):
if current_len + len(m["content"]) > max_chars:
break
trimmed.insert(0, m)
current_len += len(m["content"])
return system_msgs + trimmed
这个截断逻辑比较基础,但很实用。实际做多轮对话系统时,历史消息累计速度很快,如果不做清理,几轮之后就会顶到上下文上限。
4.2 LangChain与LangGraph的选择困惑
有不少人问我,LangChain和LangGraph到底该用哪个?我在做这套API封装时也纠结过这个问题。
LangChain和LangGraph是同一个生态下的两种工具。LangChain适合处理"线性或简单分支"的LLM调用流程,就像我上面做的这个API封装,输入消息、调用模型、返回结果,没有复杂的条件跳转和循环,用LangChain足够,代码也简洁。LangGraph则适合处理有状态、多节点、需要条件路由的复杂Agent流程,比如一个带反思机制、多工具调用的智能体系统。
我的建议是:如果你只是做模型接入和API封装,LangChain完全够用;如果你要构建一个需要多步骤决策的Agent,再考虑LangGraph。不要在简单场景里引入过重的东西,这一点在选型上尤其重要。
4.3 模型切换时的参数覆盖陷阱
我在测试阶段遇到过一个问题:同一个API服务中,请求A指定用qwen2.5:7b,请求B指定用llama3.1:8b,但B请求实际返回的内容还是qwen的。
排查下来发现原因在LLMService内部的参数覆盖逻辑不彻底。在chat方法中,虽然写了slef.model和slef.llm的重新初始化逻辑,但这段代码在并发场景下会出现竞态条件——请求A刚把model改成qwen,请求B又把model改成llama,最后两个请求可能都用了llama。
解决方案是不要在服务实例内共用同一个LLM实例,而是为每次请求动态创建模型实例,或者更简单一点,把模型列表做成一个字典缓存:
python复制class LLMService:
def __init__(self, base_url: str = "http://localhost:11434", default_model: str = "qwen2.5:7b"):
self.base_url = base_url
self.default_model = default_model
self._llm_cache = {}
def _get_llm(self, model: str, temperature: float = None):
cache_key = f"{model}_{temperature}"
if cache_key not in self._llm_cache:
self._llm_cache[cache_key] = ChatOllama(
model=model,
base_url=self.base_url,
temperature=temperature if temperature is not None else 0.7,
num_ctx=8192,
num_predict=2048
)
return self._llm_cache[cache_key]
这样每个模型+温度组合都有独立的LLM实例,互相不干扰。
4.4 并发调用时的Ollama队列问题
最后一个值得单独拎出来说的坑是并发问题。Ollama默认会串行处理请求,也就是同一时间只能跑一个推理任务,后来的请求会排队等待。如果你的API服务有多个并发调用,直接打到Ollama上,会发现响应时间突然变得很长。
这个问题的处理方案要看场景。如果是内部系统,并发量不大,串行排队问题不大,排队等待也就几秒。如果并发量大,就要考虑部署多个Ollama实例,或者用GPU推理时给不同模型指定不同显存。我这里针对目前的使用量,暂时维持单实例方案,但我在代码里预留了Ollama实例的配置化接口,将来并发上来了,可以平滑切换到多实例负载均衡。
另外还有个实用建议:Ollama推理时显存占用很激进,如果同时加载多个不同的大模型在显存里放不下,它会频繁切换模型,导致性能骤降。如果你有多个模型要提供服务,优先考虑在API层做模型路由,不要依赖Ollama自动加载。
5. 完整项目结构与你需要特别注意的几个点
最后放一下这套封装的项目结构,方便你参考:
text复制llm-api/
├── main.py # FastAPI入口,路由定义
├── services/
│ ├── __init__.py
│ └── llm_service.py # LLM封装核心逻辑
├── schemas/
│ ├── __init__.py
│ └── chat.py # Pydantic请求/响应模型
├── requirements.txt # 依赖清单
└── README.md # 使用文档
依赖清单requirements.txt如下:
text复制langchain==0.2.16
langchain-ollama==0.2.0
fastapi==0.115.6
uvicorn[standard]==0.32.1
pydantic==2.10.4
requests==2.32.3
有几个点我想特别叮嘱一下:
第一,langchain-ollama这个包的版本要和你安装的LangChain主版本匹配,不然容易出现ChatOllama导入报错。遇到这种问题先检查版本。
第二,生产环境部署时,不要直接暴露FastAPI端口给公网,前面至少要挂一层Nginx做代理,再加一下访问密钥鉴权。我们内部系统目前的做法是在Nginx层做了一个简单的Token校验,虽然简单但不失为一种有效保护手段。
第三,日志和监控要提前做好。LLM服务的延迟和错误率波动比较大,尤其在不同模型之间切换时,没有日志你很难定位问题是出在API层还是Ollama推理层。我在llm_service.py里加的logging只是最基础的,实际生产里建议配合Prometheus做指标采集。
我在实际使用中发现,这套封装最大的价值在于它定义了一个稳定的接口边界。模型可以换、参数可以调、甚至Ollama换成vLLM都不需要改动API层代码。对我个人而言,踩过那个400上下文超限的坑之后,我把上下文管理和参数约束纳入了封装的标准流程,以后再接到类似的模型项目,至少能少熬两个夜。这套代码的整体结构并不复杂,但它把LangChain + Ollama这套本地模型技术栈真正接到了生产场景,希望这份记录对你也有同样的帮助。
