选读:工业级调用 LangChain:从 Demo 到企业级应用

选读:工业级调用 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:可观测

目标:出问题能查日志,而不是猜。

  • printlogging
  • 记录: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 / deepseek
  • api_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      # 调用问题

读代码的顺序:

  1. 先看 main() --- 这就是业务流程
  2. 再看 ask_llm_stream() --- 模型怎么调的
  3. 最后看 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 到企业级,不是写更多代码,而是让代码承担更多责任

  1. 配置外置 --- Key 不进 Git,换环境改 .env
  2. 分层 --- 主逻辑留中间,配置和基础设施放两边
  3. 可观测 --- 日志记 model、耗时,Key 脱敏
  4. 可靠性 --- 超时、重试、退出码
  5. 体验 --- 流式输出,用户不用干等

最后记住:

企业级不是一开始就复杂,而是每一步改造都让「主逻辑」更清晰,而不是更淹没。

打开 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

看到流式输出的模型回复,说明企业级改造跑通。

相关推荐
颜酱1 小时前
LangChain 调用大模型实战:从跑通到服务商与模型选型
python·langchain
唐装鼠2 小时前
Nginx + Gunicorn + Python Web 应用 架构(Claude)
python·nginx·gunicorn
梦想三三2 小时前
【PYthon词频统计与文本向量化】苏宁易购评论分析实战
开发语言·python
biter down3 小时前
9:JSONSchema
python
日晨难再3 小时前
C语言&Python&Bash&Tcl:全局变量和局部变量
c语言·python·bash·tcl
麻雀飞吧3 小时前
期货量化主连和具体合约怎么切:天勤 KQ.m 与 KQ.i 用法
python·区块链
先吃饱再说3 小时前
Python List 切片与 LLM Prompt 设计:从数据结构到接口调用
python
一只专注api接口开发的技术猿4 小时前
OpenClaw 对接淘宝商品 API,低成本实现全天候选品监控|附可运行 Python 实操代码
大数据·开发语言·数据库·python
xingpanvip4 小时前
星盘接口开发文档:马盘次限盘接口指南
android·开发语言·python·php·lua