1. 从脚本到智能体:LLM 应用开发到底在解决什么问题
很多人第一次接触大语言模型应用开发,脑子里浮现的画面是打开一个聊天窗口,输入问题,等它吐出一段文字。这个认知本身没错,但如果你的目标是把 LLM 变成产品里真正能跑起来的一环,那这个画面就太单薄了。我做了几个把模型接进实际业务的项目之后,最深的感受是:模型本身只是发动机,真正决定这辆车能不能上路的,是传动系统、油路、刹车和仪表盘——也就是围绕模型构建的那一整套工程结构。
这一篇要聊的,就是把 LLM 融入现代开发工作流这件事。核心关键词是 Python、LangChain、Docker 和云平台。Python 是这门手艺的通用语言,LangChain 是把模型、提示词、工具、记忆串起来的胶水层,Docker 负责让整套东西在任何机器上都能一模一样地跑起来,云平台则是最终把它交付出去的地方。这四个东西凑在一起,基本覆盖了从本地原型到线上服务的完整链路。
适合谁来读?如果你已经会写一点 Python,能看懂函数和类,但对“怎么把模型接进一个真实系统”还没有清晰路径,那这篇就是给你准备的。如果你已经在用 LangChain 但总觉得结构乱、部署烦、换台机器就崩,那也能从里面找到一些能直接抄的配置和思路。我不打算把它写成一份 API 手册,而是按一个真实项目的推进顺序来讲:先想清楚架构,再抠细节,然后动手搭,最后处理那些一定会冒出来的坑。
需要提前说明的是,下面涉及的具体版本、参数和目录结构,都是基于我实际项目里验证过的常见实践做的合理补全。不同团队的习惯会有差异,但底层的取舍逻辑是通用的,你可以根据自己的场景调整。
2. 整体架构怎么设计:为什么是 Python + LangChain + Docker 这套组合
2.1 为什么 Python 是 LLM 工作流的默认选择
先回答一个很多人心里嘀咕的问题:为什么大模型应用开发几乎一边倒地用 Python,而不是 Go、Java 或者 Node.js?答案不复杂,但值得说透。
第一是生态。模型推理、向量检索、文本处理、数据清洗这些环节,Python 的库密度是最高的。你想做个文本分块,有现成的;想接个向量数据库,官方 SDK 基本都先出 Python 版;想调各家模型接口,Python 客户端往往是最先更新、文档最全的那个。用别的语言不是不能做,而是你会在每个环节都多花时间去找轮子或者自己造轮子。
第二是迭代速度。LLM 应用的开发特点是“试错密集”——提示词改一版、参数调一下、换个模型对比效果,这些动作需要极短的反馈循环。Python 的动态特性和交互式环境(比如 Jupyter、IPython)让这种快速试验变得非常顺手。你可以在一个 notebook 里把整条链路跑通,确认思路对了再重构成工程代码。
第三是团队协作成本。数据、算法、后端这几拨人如果都用 Python,交接时不用翻译。一个做数据标注的同事写的数据处理脚本,后端可以直接拿来用,不用重写。
注意:Python 的版本选择别太激进。我一般锁在 3.10 或 3.11,这两个版本在依赖兼容性上最稳。3.12 之后有些库的 wheel 还没跟上,装依赖时容易卡在编译环节,新手很容易在这里耗掉半天。
2.2 LangChain 在架构里扮演什么角色
LangChain 经常被误解成“一个调用模型的库”。如果只是调模型,你直接用官方 SDK 就够了,没必要引入这一层。LangChain 真正的价值在于它把 LLM 应用里反复出现的几个模式抽象成了可组合的组件。
我把它拆成四块来看:
- 模型抽象层:不管你用的是哪家的模型,接口形式统一。今天用 A 模型,明天想换 B 模型对比,改一行配置就行,不用重写业务逻辑。这在做模型选型时特别省事。
- 提示词管理:把提示词从代码里抽出来,做成模板,支持变量填充、少样本示例、输出格式约束。提示词一旦复杂起来,硬编码在字符串里会变成维护噩梦。
- 链式编排:把“取数据 → 构造提示 → 调模型 → 解析输出 → 存结果”这一串步骤串成一条链。每一步的输入输出清晰,方便调试和替换。
- 工具与智能体:让模型能调用外部函数,比如查数据库、算数、调接口。这是从“聊天机器人”迈向“能干活的应用”的关键一步。
LangGraph 是 LangChain 生态里更偏底层编排的一个补充。当你需要循环、条件分支、多角色协作这类复杂控制流时,普通的链式结构会变得别扭,这时候用图结构来表达状态流转会清晰很多。我的经验是:简单线性流程用普通链,涉及“模型自己决定下一步走哪”的场景再上 LangGraph,不要一上来就把它用复杂。
2.3 Docker 解决的是“在我机器上能跑”这个老问题
LLM 项目的依赖比普通 Web 项目更麻烦。除了常规的 Python 包,你可能还要装系统级的库、特定版本的编译工具、模型运行时的依赖。这些东西在不同操作系统、不同机器上表现不一致,导致一个很常见的场景:本地跑得好好的,部署到服务器就报错,而且报的错还看不懂。
Docker 把应用和它的运行环境打包成一个镜像,镜像在哪儿跑都一样。对 LLM 项目来说,这带来三个直接好处:
- 环境一致性:开发、测试、生产用同一个镜像,消除了“环境差异”这个变量。
- 依赖隔离:不同项目用不同版本的库,互不干扰。你可以在一个机器上同时跑两个依赖冲突的项目。
- 部署简化:云平台上部署时,你交付的是一个镜像,而不是一堆安装说明。平台拉取镜像就能跑,省去了在服务器上一步步配环境的麻烦。
2.4 云平台在链路末端的位置
本地开发完成后,最终要放到云平台上对外提供服务。云平台的角色是提供计算资源、网络入口和运维能力。对 LLM 应用来说,选云平台时我会重点看三件事:能不能方便地跑容器、有没有 GPU 资源(如果模型要本地推理)、网络和存储的计费是否透明。
很多云平台现在都提供了容器服务,你把自己的镜像推上去,配好端口和环境变量,它就能帮你跑起来。这一步的关键不是平台本身多高级,而是你的镜像要做得足够“自包含”——所有配置通过环境变量注入,不依赖镜像外的任何文件。
3. 核心细节拆解:从项目结构到关键配置
3.1 一个能长期维护的项目目录长什么样
新手最容易犯的错是把所有代码堆在一个文件里。原型阶段可以,但只要项目稍微长大一点,就会变成一团乱麻。我推荐的结构是这样的:
llm-app/ ├── app/ │ ├── __init__.py │ ├── main.py # 服务入口 │ ├── chains/ # 各种链的定义 │ │ ├── __init__.py │ │ └── qa_chain.py │ ├── prompts/ # 提示词模板 │ │ └── qa_prompt.py │ ├── tools/ # 自定义工具函数 │ │ └── search_tool.py │ ├── models/ # 模型客户端封装 │ │ └── llm_client.py │ └── config.py # 配置读取 ├── tests/ # 测试 ├── .env # 本地环境变量(不提交) ├── .env.example # 环境变量模板 ├── requirements.txt # 依赖清单 ├── Dockerfile # 镜像构建 └── docker-compose.yml # 本地编排这个结构的好处是职责分明。提示词改动去 prompts 目录,换模型去 models 目录,加新功能去 chains 或 tools。团队协作时,不同人改不同目录,冲突概率低。
提示:
.env文件一定要放进.gitignore。我见过不止一次有人把带密钥的配置文件提交到仓库,后面清理起来非常麻烦。养成习惯,从项目第一天就建.env.example,里面只放键名不放值。
3.2 依赖管理:requirements.txt 怎么写才不踩坑
依赖清单看起来简单,其实有讲究。我一般遵循几个原则:
- 锁定版本:不要写
langchain,要写langchain==0.1.x这样的具体版本。不锁版本的话,今天能跑的代码,下周别人拉下来可能就因为某个依赖升级而崩了。 - 分层管理:如果项目大,可以拆成
requirements.txt(核心)和requirements-dev.txt(开发工具)。生产镜像只装核心依赖,镜像更小。 - 定期更新:锁版本不等于永远不更新。我会每隔一段时间专门做一次依赖升级,跑一遍测试,确认没问题再合并。不要等到不得不升级时才动,那时候积累的变更太多,排查困难。
一个典型的requirements.txt大概长这样:
langchain==0.1.20 langchain-community==0.0.38 langgraph==0.0.60 python-dotenv==1.0.1 fastapi==0.111.0 uvicorn==0.30.1 pydantic==2.7.13.3 配置与密钥管理:别把密钥写死在代码里
模型接口的密钥、数据库连接串这些东西,绝对不能硬编码。标准做法是通过环境变量注入。本地开发时用.env文件配合python-dotenv读取,线上则由云平台的环境变量配置提供。
# app/config.py import os from dotenv import load_dotenv load_dotenv() class Settings: MODEL_API_KEY = os.getenv("MODEL_API_KEY") MODEL_BASE_URL = os.getenv("MODEL_BASE_URL", "https://api.example.com/v1") MODEL_NAME = os.getenv("MODEL_NAME", "default-model") LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") settings = Settings()这样写的好处是,同一份代码在本地和线上都能跑,区别只在环境变量。切换模型也只需要改环境变量,不用动代码。
注意:读取环境变量时给个合理的默认值,但密钥类的不要给默认值。如果密钥没配,应该让程序在启动时就明确报错,而不是带着空密钥跑起来,等到调用时才失败,那样排查起来更费劲。
4. 实操过程:从零把应用跑起来
4.1 本地环境准备与依赖安装
第一步是把 Python 环境弄干净。我强烈建议用虚拟环境,不要往系统 Python 里装项目依赖。虚拟环境的好处是隔离,删掉重建成本极低。
# 创建虚拟环境 python -m venv venv # 激活(Linux/macOS) source venv/bin/activate # 激活(Windows) venv\Scripts\activate # 安装依赖 pip install -r requirements.txt装完之后验证一下关键库能不能导入:
python -c "import langchain; print(langchain.__version__)"如果这一步报错,先别往下走,把报错信息看清楚。常见的是某个依赖编译失败,通常是因为缺少系统级的编译工具。Linux 上可能需要装build-essential和python3-dev,这些在后面的 Dockerfile 里也会体现。
4.2 写一条最小可用的链
在动手做复杂功能前,先跑通一条最简单的链,确认模型能调通。这一步的价值在于排除环境问题,把变量降到最少。
# app/chains/qa_chain.py from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser from app.models.llm_client import get_llm def build_qa_chain(): prompt = ChatPromptTemplate.from_messages([ ("system", "你是一个严谨的助手,回答要基于事实,不确定时明确说明。"), ("human", "{question}") ]) llm = get_llm() chain = prompt | llm | StrOutputParser() return chain if __name__ == "__main__": chain = build_qa_chain() result = chain.invoke({"question": "解释一下什么是向量检索"}) print(result)这里用了 LangChain 的管道语法,prompt | llm | parser读起来就是数据流动的方向。这种写法比老式的LLMChain更直观,也是现在推荐的方式。
4.3 用 FastAPI 把链包装成服务
本地能跑之后,下一步是把它变成一个有接口的服务。FastAPI 是我用得最多的选择,因为它轻、快、自带交互式文档。
# app/main.py from fastapi import FastAPI from pydantic import BaseModel from app.chains.qa_chain import build_qa_chain app = FastAPI(title="LLM Service") chain = build_qa_chain() class QueryRequest(BaseModel): question: str class QueryResponse(BaseModel): answer: str @app.post("/ask", response_model=QueryResponse) def ask(req: QueryRequest): answer = chain.invoke({"question": req.question}) return QueryResponse(answer=answer) @app.get("/health") def health(): return {"status": "ok"}启动服务:
uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload--reload只在开发时用,它会在代码改动后自动重启。生产环境要去掉,否则性能和稳定性都有问题。
4.4 编写 Dockerfile:把环境固化下来
这是整个流程里最关键的一步。Dockerfile 写得好不好,直接决定部署顺不顺。
FROM python:3.11-slim WORKDIR /app # 先装系统依赖,再装 Python 依赖,利用镜像层缓存 RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ && rm -rf /var/lib/apt/lists/* COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app/ ./app/ EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]几个细节值得说:
- 用
python:3.11-slim而不是完整版,镜像体积小很多,拉取快。 - 先 COPY requirements 再 RUN pip install,这样只要依赖没变,改代码时不会重新装依赖,构建速度快。
--no-cache-dir避免 pip 缓存占空间。- 不把
.env拷进镜像,环境变量在运行时注入。
构建和运行:
docker build -t llm-app:latest . docker run -p 8000:8000 --env-file .env llm-app:latest4.5 用 docker-compose 编排多服务
真实项目往往不止一个服务,可能还有数据库、缓存、向量库。用 docker-compose 把这些串起来,一条命令全起来。
version: "3.9" services: app: build: . ports: - "8000:8000" env_file: - .env depends_on: - redis redis: image: redis:7-alpine ports: - "6379:6379"depends_on保证启动顺序,但要注意它只保证容器启动,不保证服务就绪。如果应用启动时就要连 Redis,最好在代码里加个重试逻辑,别假设依赖一定已经准备好了。
4.6 部署到云平台的关键动作
镜像做好之后,部署到云平台大致是这几步:把镜像推到镜像仓库,在平台上创建服务并指定镜像地址,配置环境变量和端口,设置健康检查路径。
健康检查我一般指向/health这个接口。平台会定期访问它,返回正常就认为服务健康。这个接口要做得足够轻,不要在里面调模型或者查数据库,否则健康检查本身会拖慢服务。
提示:云平台上的环境变量配置界面通常支持批量粘贴。我习惯把
.env.example里的键整理好,一次性贴进去再填值,比一个个手动加快很多,也不容易漏。
5. 常见问题与排查技巧实录
5.1 依赖安装失败:先看是不是编译工具缺失
这是新手遇到最多的坑。表现是pip install到某个包时卡住,然后报一堆编译错误。根因通常是这个包没有预编译的 wheel,需要本地编译,而系统缺少编译器或开发头文件。
排查顺序:先看报错里提到的包名,去它的文档确认是否需要系统依赖。Linux 上常见的补救是装build-essential、python3-dev。如果是在 Docker 里,把这些装到镜像里;如果是本地,装到系统里。另一个思路是找有没有纯 Python 的替代包,或者升级 pip 到最新版,有时候新版 pip 能拉到之前拉不到的 wheel。
5.2 模型调用超时:区分是网络问题还是模型慢
超时报错很常见,但要分清原因。如果是网络不通,通常会很快失败;如果是模型处理慢,会等到超时时间才失败。前者检查网络配置和接口地址,后者考虑调大超时时间或者换更快的模型。
我在代码里一般会显式设置超时和重试:
from langchain_openai import ChatOpenAI llm = ChatOpenAI( model=settings.MODEL_NAME, timeout=60, max_retries=2, )重试次数不要设太多,否则一个请求卡很久。两次足够应对偶发的网络抖动。
5.3 容器启动后立刻退出:看日志,别猜
容器起来就挂,第一件事是看日志:
docker logs <container_id>常见原因有几个:启动命令写错、环境变量缺失导致代码在导入时就抛异常、端口被占用。我遇到最多的是环境变量没传进去,代码在模块加载阶段就读配置,读不到直接崩。解决办法是把配置读取做成延迟加载,或者在启动脚本里先校验必需的环境变量,缺了就打印清晰的错误信息再退出。
5.4 本地能跑容器里不能跑:路径和大小写问题
这个问题的经典原因是文件路径。本地开发时可能用了相对路径,容器里的工作目录不一样,就找不到了。另一个原因是文件名大小写,Linux 文件系统区分大小写,Windows 不区分,本地能导入的模块到容器里就报找不到。
解决办法是统一用绝对路径或者基于项目根的相对路径,并且养成文件名全小写的习惯。导入语句里的模块名也要和实际文件名大小写一致。
5.5 常见问题速查表
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| pip 安装卡在编译 | 缺系统编译工具 | 装 build-essential、python3-dev |
| 模型调用超时 | 网络不通或模型慢 | 检查接口地址,调大 timeout |
| 容器启动即退出 | 环境变量缺失或命令错误 | docker logs 看报错 |
| 容器内模块找不到 | 路径或大小写问题 | 统一路径,文件名小写 |
| 健康检查失败 | 接口太重或端口不对 | 简化 /health,核对端口映射 |
| 换机器后行为不一致 | 依赖未锁版本 | 锁定 requirements 版本 |
5.6 几个我踩过之后才明白的经验
第一,日志要打够。LLM 应用的调试比普通应用难,因为模型输出有随机性。我会在关键节点打日志:请求进来、提示词构造完、模型返回、解析结果。出问题时能快速定位是哪一步不对。
第二,提示词要版本化。提示词的改动对效果影响很大,但很容易改着改着就忘了哪版效果好。我习惯把提示词放在单独文件里,改动时在注释里记一笔改了什么、为什么改。
第三,别在健康检查里做重活。这个前面提过,但值得再强调。健康检查频率高,里面做重活会拖垮服务。
第四,本地开发用 docker-compose,别一个个手动起容器。手动起容易漏、容易忘,compose 文件本身就是一份可执行的文档。
第五,模型输出一定要做解析和校验。不要假设模型一定按你要求的格式返回。用结构化输出或者加一层解析容错,能省掉很多线上问题。
6. 把智能体接进来:从单次问答到多步任务
6.1 什么时候该上智能体
单次问答能解决很多问题,但有些任务需要模型自己决定“下一步做什么”。比如用户问“帮我查一下最近的订单状态并总结”,这需要先调查询接口,拿到数据后再总结。这种“模型决定调用哪个工具、按什么顺序调”的场景,就是智能体的用武之地。
判断标准很简单:如果任务的步骤是固定的,用普通链就够了;如果步骤取决于中间结果,需要动态决策,才考虑智能体。不要为了用智能体而用智能体,它带来的复杂度和不确定性是实打实的。
6.2 工具定义与调用
工具本质上就是一个函数,加上清晰的描述,让模型知道什么时候该用它。
from langchain_core.tools import tool @tool def get_order_status(order_id: str) -> str: """根据订单号查询订单状态。输入应该是订单号字符串。""" # 实际项目里这里会查数据库或调接口 return f"订单 {order_id} 状态:已发货"描述写得好不好,直接决定模型会不会正确调用。描述里要说清楚这个工具做什么、输入是什么格式。含糊的描述会让模型乱调或者不调。
6.3 用 LangGraph 编排带分支的流程
当流程里有循环和条件分支时,LangGraph 的图结构比链式更清晰。你可以把每个处理步骤定义成一个节点,节点之间的连线表示流转条件。
from langgraph.graph import StateGraph, END from typing import TypedDict class State(TypedDict): question: str needs_tool: bool answer: str def decide(state: State): # 判断是否需要调用工具 return {"needs_tool": "订单" in state["question"]} def call_tool(state: State): return {"answer": "已查询订单信息"} def direct_answer(state: State): return {"answer": "直接回答"} graph = StateGraph(State) graph.add_node("decide", decide) graph.add_node("tool", call_tool) graph.add_node("direct", direct_answer) graph.set_entry_point("decide") graph.add_conditional_edges("decide", lambda s: "tool" if s["needs_tool"] else "direct") graph.add_edge("tool", END) graph.add_edge("direct", END) app_graph = graph.compile()这段代码展示的是核心思路:状态在节点间流动,条件边决定走哪条路。实际项目里状态会更复杂,节点也会更多,但骨架就是这样。
注意:智能体的循环一定要设上限。模型有可能陷入反复调用同一个工具的循环,没有上限的话会一直烧资源。LangGraph 里可以设置递归限制,或者自己在状态里加个计数器。
7. 性能与成本:上线前必须算的账
7.1 延迟从哪来,怎么优化
LLM 应用的延迟主要来自三块:网络往返、模型推理、后处理。网络往返取决于你和模型服务的距离,这个通常改不了。模型推理时间取决于模型大小和输出长度,可以通过选更小的模型、限制输出长度来优化。后处理包括解析、格式化、再调一次模型等,这部分是自己代码能控制的,要尽量精简。
一个实用的优化是流式输出。用户不用等完整结果,看到第一个字就能开始读,体感延迟大幅降低。FastAPI 配合流式响应可以做,LangChain 的模型客户端也支持流式回调。
7.2 成本控制的实际做法
模型调用是按量计费的,成本控制要从几个方面入手。缓存是最直接的手段,相同或相似的请求直接返回缓存结果。提示词要精简,别塞一堆用不上的上下文。输出长度要限制,很多场景不需要长篇大论。批量处理能合并的请求,减少调用次数。
我会在代码里记录每次调用的 token 用量,定期看统计,找出消耗大的环节。没有度量就没法优化,这一步不能省。
8. 写在最后:一些真实的体会
这套东西我从头搭过几遍,每次都会在某个环节卡一下,但卡完之后对整体理解就更深一层。最开始我觉得 Docker 是负担,觉得本地能跑就行了,直到有一次换服务器部署,环境差异让我折腾了一整天,从那以后我就老老实实把镜像做好。LangChain 也是,一开始觉得它抽象太多,不如直接调 SDK 清爽,但项目一大,没有这层抽象,代码会乱得没法维护。
如果你正准备动手,我的建议是先跑通最小闭环:一条链、一个接口、一个镜像、一次部署。别一上来就追求功能齐全,先把链路打通,后面加功能就是在这个骨架上长肉。遇到报错别慌,大部分问题都能通过看日志定位,剩下的靠搜索和试错也能解决。
这个方向还在快速变化,今天好用的方法明天可能就有更好的替代。保持动手,保持记录,比记住某个具体 API 更重要。