选读:工业级调用 LangChain:从 Demo 到企业级应用
TL;DR
Demo 证明「能跑」,企业级解决「能跑、可控、可排错、可切换、可上线」。
- Demo:11 行,
print出结果就行 - 企业级:配置外置 → 分层 → 日志 → 超时重试 → 流式 → 退出码,主逻辑仍然只有「问模型、拿答案」
- 改造顺序:先分层让主逻辑清晰,再补日志和配置,最后加重试和流式------不要一上来就微服务
- 完整代码见文末附录 (
settings.py/llm_factory.py/use-model.py+.env模板) - 前置阅读:《LangChain 调用大模型实战:从跑通到服务商与模型选型》(建议先跑通 Demo 再读本文)
一、为什么 Demo 不够?
上一篇你已经跑通了这样的代码:
python
import os
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(
api_key=os.getenv("SILICON_KEY"),
base_url=os.getenv("SILICON_BASE_URL"),
model="deepseek-ai/DeepSeek-V3",
)
print(llm.invoke("用一句话介绍你自己").content)
本地执行,终端有输出------任务完成。
但把它交给同事部署、交给运维上线、交给老板做 ToB 项目,会立刻遇到这些问题:
| 场景 | Demo 的问题 |
|---|---|
| 同事 clone 代码跑不起来 | Key 在你本机 ~/.zshrc 里,别人没有 |
| 线上报错 | 只有 Python 栈追踪,不知道调了哪个模型、耗时多少 |
| 硅基切百炼 | 要改代码里的 base_url 和 Key |
| API 超时 | 程序卡死,没有重试 |
| 用户体验 | 等 5 秒才一次性出字,像卡住了 |
| 运维排查 | 进程退出码永远是 0 或崩溃,分不清配置错还是网络错 |
Demo 的目标是学习;企业级的目标是可持续运行。
二、核心思维:先看清主逻辑,再包基础设施
很多人一听说「企业级」,就想到微服务、K8s、消息队列------对于 LLM 调用来说,那是后面的事。
第一步要想清楚的是:
主逻辑 = 读配置 → 调模型 → 返回答案
不管 Demo 还是企业级,业务本质不变。企业级做的是在主逻辑外面加「壳」:
scss
┌─────────────────────────────────────┐
│ run() ← 异常兜底、退出码 │
│ ┌───────────────────────────────┐ │
│ │ main() ← 主逻辑(你要看的)│ │
│ │ ask_llm() ← 调模型 │ │
│ └───────────────────────────────┘ │
│ settings.py ← 配置 │
│ llm_factory ← 创建客户端、日志重试 │
└─────────────────────────────────────┘
口诀:主逻辑留中间,脏活放两边。
三、Demo → 企业级:改什么、不改什么
| 维度 | Demo | 企业级 | 改的原因 |
|---|---|---|---|
| 密钥 | os.getenv 散落代码 |
settings.py + .env |
集中管理、启动校验、不进 Git |
| 写法 | init_chat_model 简写 |
ChatOpenAI 显式传参 |
多服务商并存、参数可审计 |
| 输出 | print 完整答案 |
stream 流式 + 结构化日志 |
体验好、可监控 |
| 异常 | 直接崩溃 | 分类捕获 + 退出码 | 运维能区分配置错 / 调用错 |
| 可靠性 | 无 | 超时 + 重试 | 网络抖动不至于整体失败 |
| 文件 | 1 个 .py |
3 个模块 | 各管一事,主逻辑不被淹没 |
不改的:
- LangChain 调用方式(仍是 OpenAI 兼容协议)
- 换模型只改
model、换服务商只改 Key + URL - 核心就一行业务:问模型,拿答案
四、改造路线(推荐顺序)
不要一次全做完。按这个顺序,每一步都能跑、都能感知价值:
阶段 1:配置外置
目标 :任何人 clone 后,复制 .env.example 就能跑。
bash
.env.example → 模板,提交 Git
.env → 真实 Key,加入 .gitignore
settings.py → 读取 + 校验
思维转变:代码里不出现 Key,不出现服务商地址。
阶段 2:分层
目标 :打开 use-model.py,3 秒内看到主逻辑。
perl
settings.py --- 我只管配置
llm_factory.py --- 我只管创建 LLM、打日志、重试
use-model.py --- 我只管业务流程
思维转变:一个文件只做一件事。
阶段 3:可观测
目标:出问题能查日志,而不是猜。
print→logging- 记录:provider、model、耗时、prompt 长度(不记录 Key 明文)
- Key 脱敏:
sk-1a...9f3
阶段 4:可靠性
目标:网络抖一下,不要整个请求废掉。
timeout=60--- 防止无限等待max_retries=3--- HTTP 层重试tenacity--- 连接层指数退避重试
阶段 5:流式输出
目标:用户不用干等,体验像 ChatGPT。
python
for token in ask_llm_stream(prompt, settings):
print(token, end="", flush=True)
五、代码思路:三个文件各干什么
1. settings.py --- 配置的单一真相源
python
class Settings(BaseSettings):
llm_provider: Provider = Field(default=Provider.SILICON, alias="LLM_PROVIDER")
silicon_key: str | None = Field(default=None, alias="SILICON_KEY")
model: str = Field(default="deepseek-ai/DeepSeek-V3", alias="LLM_MODEL")
timeout: float = Field(default=60.0, alias="LLM_TIMEOUT")
...
设计要点:
- 用
pydantic-settings自动读.env,带类型校验 LLM_PROVIDER一个变量切换 silicon / bailian / deepseekapi_key_and_base_url()把「选哪家服务商」封装起来,业务层不用 if-else- 缺 Key 时
raise ValueError,启动就失败(快速失败优于线上裸奔)
.env 示例:
env
LLM_PROVIDER=silicon
SILICON_KEY=sk-xxx
LLM_MODEL=deepseek-ai/DeepSeek-V3
LLM_TIMEOUT=60
LOG_LEVEL=INFO
上线百炼?只改 .env:
env
LLM_PROVIDER=bailian
DASHSCOPE_KEY=sk-xxx
LLM_MODEL=deepseek-r1
代码一行不动。
2. llm_factory.py --- 基础设施层
这一层承担所有「脏活」:创建客户端、打日志、重试、流式。
python
def create_chat_model(settings: Settings) -> BaseChatModel:
api_key, base_url = settings.api_key_and_base_url()
return ChatOpenAI(
api_key=api_key,
base_url=base_url,
model=settings.model,
timeout=settings.timeout,
max_retries=settings.max_retries,
)
为什么企业场景用 ChatOpenAI 而不是 init_chat_model?
ChatOpenAI |
init_chat_model |
|
|---|---|---|
| 参数 | 全部显式 | 靠环境变量 + 前缀 |
| 多 Key 并存 | 容易,各实例各传参 | 要反复改 OPENAI_* |
| 代码审查 | 一眼看出连的哪家 | 要追环境变量 |
| 适合 | 代理平台、企业多环境 | 官方原生、快速原型 |
LLMService 包装 invoke / stream:
python
class LLMService:
def invoke(self, prompt: str) -> str: ... # 一次性返回,带重试和计时
def stream(self, prompt: str) -> Iterator[str]: ... # 流式,逐 token
对外暴露简洁入口:
python
def ask_llm_stream(prompt: str, settings: Settings | None = None) -> Iterator[str]:
cfg = settings or Settings()
service = LLMService(create_chat_model(cfg), cfg)
yield from service.stream(prompt)
业务层只需要知道 ask_llm_stream(prompt),不需要知道 ChatOpenAI 怎么构造。
3. use-model.py --- 入口,主逻辑一目了然
python
def main() -> int:
settings = Settings()
setup_logging(settings.log_level)
# ===== 主逻辑 =====
for token in ask_llm_stream("用一句话介绍你自己", settings):
print(token, end="", flush=True)
print()
# ==================
return 0
def run() -> int:
"""异常不污染 main,统一在这里处理。"""
try:
return main()
except ValueError as exc:
print(f"配置错误: {exc}", file=sys.stderr)
return 1 # 配置问题
except Exception:
logging.exception("调用失败")
return 2 # 调用问题
读代码的顺序:
- 先看
main()--- 这就是业务流程 - 再看
ask_llm_stream()--- 模型怎么调的 - 最后看
settings.py/LLMService--- 需要调优时再看
退出码约定:
| 码 | 含义 | 谁处理 |
|---|---|---|
| 0 | 成功 | --- |
| 1 | 配置错误(缺 Key、.provider 不对) | 开发 / 运维改 .env |
| 2 | 调用失败(网络、API 限流、模型不存在) | 查日志、联系服务商 |
六、整体架构一图流
scss
用户 / 脚本
│
▼
use-model.py ─── main() ─── 主逻辑(流式打印)
│ │
│ └── run() ─── 异常兜底
│
▼
llm_factory.py ─── ask_llm_stream()
│ │
│ ├── create_chat_model() → ChatOpenAI
│ └── LLMService.stream() → 日志 + 计时
│
▼
settings.py ─── Settings ─── .env / 环境变量
│
├── LLM_PROVIDER → 选服务商
├── LLM_MODEL → 选模型
└── timeout/retry → 可靠性参数
七、invoke vs stream:什么时候用哪个
invoke / ask_llm |
stream / ask_llm_stream |
|
|---|---|---|
| 返回 | 等全部生成完,一次性返回 | 边生成边返回 token |
| 适合 | 后台任务、Agent 链、需要完整 JSON | 聊天 UI、CLI 交互 |
| 重试 | 整请求重试,简单 | 流中断重试复杂,一般不重试整流 |
| 本项目 | LLMService.invoke 带 tenacity |
LLMService.stream 只记日志 |
建议:面向用户的接口用 stream;内部编排、工具调用用 invoke。
八、企业级还缺什么?(路线图)
当前代码是 「可上线的基础版」,离完整生产还有距离。按优先级排列:
| 优先级 | 能力 | 说明 |
|---|---|---|
| P0 | ✅ 配置外置 | 已完成 |
| P0 | ✅ 日志 + 退出码 | 已完成 |
| P0 | ✅ 超时重试 | 已完成 |
| P1 | 单元测试 | pytest + mock LLM,CI 不依赖真实 API |
| P1 | 流式 API 接口 | FastAPI StreamingResponse,而不只是 CLI |
| P2 | 链路追踪 | OpenTelemetry,记录 trace_id、token 用量 |
| P2 | 限流熔断 | 按租户限 QPS,防止打爆配额 |
| P3 | 密钥管理 | Vault / 云 KMS,而不是 .env 明文 |
| P3 | 容器化 | Dockerfile + K8s health check |
原则:先把 P0 做扎实,再按需加 P1/P2。不要 Demo 刚跑通就上 K8s。
九、常见问题
Q1:三个文件是不是 over-engineering?
对于 11 行 Demo 来说,是的。
对于 要交付给团队、要切换服务商、要查日志 的场景,三个文件刚刚好。
判断标准:主逻辑 main() 是否能在 5 行内读完? 能,就不算过度设计。
Q2:为什么异常处理放在 run() 而不是 main() 里?
因为 try/except 会淹没主逻辑。拆开后:
main()--- 给开发者看业务流程run()--- 给运维看错误分类
Q3:能不能继续用 init_chat_model?
可以,但不推荐作为企业默认方案。如果坚持用,至少要把 OPENAI_API_KEY / OPENAI_BASE_URL 的映射封装进 settings.py,别散落在业务代码里。
Q4:和上一篇教程是什么关系?
| 文章 | 解决什么 |
|---|---|
| 《LangChain 调用大模型实战:从跑通到服务商与模型选型》 | 跑通、理解概念、选型 |
| 本文 | Demo 代码怎么演进成可交付的工程 |
十、小结
从 Demo 到企业级,不是写更多代码,而是让代码承担更多责任:
- 配置外置 --- Key 不进 Git,换环境改
.env - 分层 --- 主逻辑留中间,配置和基础设施放两边
- 可观测 --- 日志记 model、耗时,Key 脱敏
- 可靠性 --- 超时、重试、退出码
- 体验 --- 流式输出,用户不用干等
最后记住:
企业级不是一开始就复杂,而是每一步改造都让「主逻辑」更清晰,而不是更淹没。
打开 use-model.py,找到 main() 里那几行 for token in ask_llm_stream(...) ------ 那就是你的业务。其余一切,都是为这几行服务的。
附录:完整代码
把下面文件放在同一目录,复制 .env 模板并填入真实 Key,即可运行。
目录结构
bash
llm-demo/
├── .env # 本地配置,勿提交 Git
├── requirements.txt
├── settings.py # 配置层
├── llm_factory.py # 客户端工厂
└── use-model.py # 入口
requirements.txt
txt
langchain>=0.3.0
langchain-openai>=0.2.0
pydantic-settings>=2.0.0
tenacity>=8.0.0
python-dotenv>=1.0.0
.env(模板)
env
# 复制为 .env 后填写,切勿提交真实 Key 到 Git
LLM_PROVIDER=silicon
SILICON_KEY=sk-xxx
SILICON_BASE_URL=https://api.siliconflow.cn/v1
# 切换百炼时:LLM_PROVIDER=bailian
# DASHSCOPE_KEY=sk-xxx
# 切换 DeepSeek 官方时:LLM_PROVIDER=deepseek
# DEEPSEEK_KEY=sk-xxx
LLM_MODEL=deepseek-ai/DeepSeek-V3
LLM_TEMPERATURE=0.7
LLM_TIMEOUT=60
LLM_MAX_RETRIES=3
LOG_LEVEL=INFO
settings.py
python
"""集中管理配置:密钥从环境变量读取,启动时校验,禁止硬编码。"""
from enum import Enum
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Provider(str, Enum):
SILICON = "silicon"
BAILIAN = "bailian"
DEEPSEEK = "deepseek"
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
# 服务商:silicon | bailian | deepseek
llm_provider: Provider = Field(default=Provider.SILICON, alias="LLM_PROVIDER")
silicon_key: str | None = Field(default=None, alias="SILICON_KEY")
silicon_base_url: str = Field(
default="https://api.siliconflow.cn/v1",
alias="SILICON_BASE_URL",
)
dashscope_key: str | None = Field(default=None, alias="DASHSCOPE_KEY")
deepseek_key: str | None = Field(default=None, alias="DEEPSEEK_KEY")
model: str = Field(default="deepseek-ai/DeepSeek-V3", alias="LLM_MODEL")
temperature: float = Field(default=0.7, ge=0.0, le=2.0, alias="LLM_TEMPERATURE")
timeout: float = Field(default=60.0, gt=0, alias="LLM_TIMEOUT")
max_retries: int = Field(default=3, ge=0, le=10, alias="LLM_MAX_RETRIES")
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
@field_validator("llm_provider", mode="before")
@classmethod
def normalize_provider(cls, value: str | Provider) -> Provider:
if isinstance(value, Provider):
return value
return Provider(value.strip().lower())
def api_key_and_base_url(self) -> tuple[str, str]:
match self.llm_provider:
case Provider.SILICON:
if not self.silicon_key:
raise ValueError("缺少 SILICON_KEY,请配置环境变量或 .env")
return self.silicon_key, self.silicon_base_url
case Provider.BAILIAN:
if not self.dashscope_key:
raise ValueError("缺少 DASHSCOPE_KEY,请配置环境变量或 .env")
return (
self.dashscope_key,
"https://dashscope.aliyuncs.com/compatible-mode/v1",
)
case Provider.DEEPSEEK:
if not self.deepseek_key:
raise ValueError("缺少 DEEPSEEK_KEY,请配置环境变量或 .env")
return self.deepseek_key, "https://api.deepseek.com/v1"
def masked_api_key(self) -> str:
key, _ = self.api_key_and_base_url()
if len(key) <= 8:
return "***"
return f"{key[:4]}...{key[-4:]}"
llm_factory.py
python
"""LLM 客户端工厂:统一创建、重试、日志,业务层只关心 invoke / stream。"""
import logging
import time
from collections.abc import Iterator
from typing import Any
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.messages import HumanMessage
from langchain_openai import ChatOpenAI
from tenacity import (
retry,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
from settings import Settings
logger = logging.getLogger(__name__)
def create_chat_model(settings: Settings) -> BaseChatModel:
"""企业场景优先 ChatOpenAI:参数显式、多服务商并存、行为可预期。"""
api_key, base_url = settings.api_key_and_base_url()
logger.info(
"init llm provider=%s model=%s base_url=%s key=%s",
settings.llm_provider.value,
settings.model,
base_url,
settings.masked_api_key(),
)
return ChatOpenAI(
api_key=api_key,
base_url=base_url,
model=settings.model,
temperature=settings.temperature,
timeout=settings.timeout,
max_retries=settings.max_retries,
)
class LLMService:
def __init__(self, llm: BaseChatModel, settings: Settings) -> None:
self._llm = llm
self._settings = settings
@retry(
retry=retry_if_exception_type((TimeoutError, ConnectionError, OSError)),
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=1, max=8),
reraise=True,
)
def invoke(self, prompt: str, **kwargs: Any) -> str:
started = time.perf_counter()
logger.info("llm request start model=%s prompt_len=%d", self._settings.model, len(prompt))
try:
response = self._llm.invoke([HumanMessage(content=prompt)], **kwargs)
content = response.content if isinstance(response.content, str) else str(response.content)
except Exception:
logger.exception("llm request failed model=%s", self._settings.model)
raise
elapsed_ms = (time.perf_counter() - started) * 1000
logger.info(
"llm request success model=%s elapsed_ms=%.1f response_len=%d",
self._settings.model,
elapsed_ms,
len(content),
)
return content
def stream(self, prompt: str, **kwargs: Any) -> Iterator[str]:
started = time.perf_counter()
logger.info("llm stream start model=%s prompt_len=%d", self._settings.model, len(prompt))
total_len = 0
try:
for chunk in self._llm.stream([HumanMessage(content=prompt)], **kwargs):
text = chunk.content if isinstance(chunk.content, str) else str(chunk.content or "")
if text:
total_len += len(text)
yield text
except Exception:
logger.exception("llm stream failed model=%s", self._settings.model)
raise
elapsed_ms = (time.perf_counter() - started) * 1000
logger.info(
"llm stream success model=%s elapsed_ms=%.1f response_len=%d",
self._settings.model,
elapsed_ms,
total_len,
)
def ask_llm(prompt: str, settings: Settings | None = None) -> str:
"""一次性返回完整答案。"""
cfg = settings or Settings()
llm = create_chat_model(cfg)
service = LLMService(llm, cfg)
return service.invoke(prompt)
def ask_llm_stream(prompt: str, settings: Settings | None = None) -> Iterator[str]:
"""流式返回答案,逐 token 产出。"""
cfg = settings or Settings()
llm = create_chat_model(cfg)
service = LLMService(llm, cfg)
yield from service.stream(prompt)
use-model.py
python
"""
企业级 LLM 调用示例。
主逻辑在 ask_llm_stream(),本文件只负责启动和打印。
配置 / 重试 / 日志 细节见 settings.py、llm_factory.py。
"""
import logging
import sys
from llm_factory import ask_llm_stream
from settings import Settings
def setup_logging(level: str) -> None:
logging.basicConfig(
level=getattr(logging, level.upper(), logging.INFO),
format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
)
def main() -> int:
settings = Settings()
setup_logging(settings.log_level)
# ===== 主逻辑:流式输出,边生成边打印 =====
for token in ask_llm_stream("用一句话介绍你自己", settings):
print(token, end="", flush=True)
print() # 结尾换行
# ==========================================
return 0
def run() -> int:
"""外层兜底:把异常翻译成退出码,不污染主逻辑。"""
try:
return main()
except ValueError as exc:
print(f"配置错误: {exc}", file=sys.stderr)
return 1
except Exception:
logging.exception("调用失败")
return 2
if __name__ == "__main__":
raise SystemExit(run())
运行
bash
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
# 把上文 .env 模板复制为 .env,填入 SILICON_KEY
python3 use-model.py
看到流式输出的模型回复,说明企业级改造跑通。