引言:Notebook很美,但生产环境不相信魔法

如果你有过这样的经历——在Jupyter Notebook里调通了模型,画出了漂亮的ROC曲线,却在把代码交给运维同事时被一句“这玩意儿怎么部署?”问得哑口无言——那么这篇文章就是为你写的。

Jupyter Notebook是一个完美的封闭宇宙:Python版本固定、依赖包版本锁定、数据路径硬编码、没有并发请求、没有网络抖动、没有下游服务超时。而生产环境是另一个维度——它由成百上千个相互依赖的组件构成,每个组件都在独立演进。一次数据库小版本升级可能让ORM生成的SQL变慢300%;一个Nginx配置参数微调可能让长连接被意外中断。

本文的核心主张是:我们不是要把Notebook“搬”到生产,而是要把它“重写”成一个能活在混沌中的服务。我们将以构建一个高可靠性的LLM智能体(Agent)服务为例,从项目结构、代码重构、配置管理、容器化到可观测性,完整展示工程化落地的全貌。


一、项目结构:从“一团乱麻”到“分层清晰”

1.1 糟糕的开始 vs 工程化的结构

很多AI项目起步时,所有文件堆在根目录下:module_a.pymodule_b.pymain_script.pytest_a.pyconfig.yamldata.txt……随着文件增多,维护变得极其困难。

工程化的第一步,就是按职责划分目录。以下是推荐的项目结构:

my-llm-agent/
├── main.py                    # 程序唯一入口
├── pyproject.toml             # 项目元数据与依赖
├── .env.example               # 环境变量模板
├── docker-compose.yml         # 本地开发编排
├── Dockerfile                 # 容器构建文件
├── src/                       # 核心源代码
│   ├── __init__.py
│   ├── config.py              # 全局配置(Pydantic Settings)
│   ├── logging.py             # 日志配置
│   ├── models/                # 数据模型(Pydantic schemas)
│   │   ├── __init__.py
│   │   ├── request.py
│   │   └── response.py
│   ├── agent/                 # Agent核心逻辑
│   │   ├── __init__.py
│   │   ├── executor.py        # Agent执行器(ReAct循环)
│   │   ├── tools.py           # 工具定义
│   │   └── prompts.py         # Prompt模板管理
│   ├── llm/                   # LLM客户端封装
│   │   ├── __init__.py
│   │   ├── client.py          # 统一API调用
│   │   └── router.py          # 模型路由(小/大模型分流)
│   ├── retrieval/             # RAG相关
│   │   ├── __init__.py
│   │   ├── vector_store.py    # 向量数据库操作
│   │   └── retriever.py       # 多路召回+重排序
│   ├── api/                   # HTTP接口层
│   │   ├── __init__.py
│   │   ├── routes.py          # FastAPI路由
│   │   └── dependencies.py    # 依赖注入
│   └── utils/                 # 工具函数
│       ├── __init__.py
│       └── validators.py      # 输入校验
├── tests/                     # 单元测试与集成测试
│   ├── __init__.py
│   ├── conftest.py            # pytest fixtures
│   ├── test_agent/
│   │   └── test_executor.py
│   └── test_api/
│       └── test_routes.py
├── notebooks/                 # 实验性Notebook(保留探索代码)
│   └── 01_prototype.ipynb
└── assets/                    # 静态资源
    └── prompts/               # 额外的Prompt模板文件

1.2 核心原则:src/与tests/分离

这个结构的核心思想是:所有可交付的代码放在src/下,测试代码放在tests/下,入口放在根目录的main.py

这样做的好处在于模块导入的一致性。当使用python main.py运行时,sys.path[0]指向项目根目录,所有from src.xxx import都能正确解析。而tests/中的测试代码通过from src.agent import executor导入,无需设置额外的PYTHONPATH环境变量。

关键点src/tests/下的文件不再直接当作脚本运行(即不能python src/agent/executor.py),而是统一通过python -m方式或main.py入口调用。这要求src/内的模块间导入使用相对导入(如from .tools import ...),保证包结构的一致性。


二、代码重构:杀死Notebook里的“幽灵依赖”

2.1 Notebook代码的三大问题

Notebook里的代码在生产环境中会暴露出三个致命问题:

  1. 隐式状态:Notebook中单元格的执行顺序决定了变量状态,这种“隐式状态”在脚本中不存在。
  2. 硬编码路径pd.read_csv('data/train.csv')在生产环境中会因为工作目录不同而崩溃。
  3. 非结构化日志print()输出的日志无法被日志收集系统(如ELK)解析。

2.2 重构三原则

原则一:所有配置通过环境变量注入

# ❌ 错误做法:硬编码
model_path = "models/v20240515.pkl"

# ✅ 正确做法:通过环境变量
import os
from pathlib import Path

MODEL_DIR = Path(os.getenv("MODEL_DIR", "/app/models"))
model_path = MODEL_DIR / "v20240515.pkl"

原则二:纯函数化设计

inference.py中的核心函数应该是纯函数——输入确定,输出确定,不依赖任何全局状态:

# src/agent/executor.py
from typing import Dict, Any, List
from src.llm.client import LLMClient
from src.models.request import AgentRequest
from src.models.response import AgentResponse

class AgentExecutor:
    """Agent执行器,纯函数式设计,输入输出均为结构化数据"""
    
    def __init__(self, llm_client: LLMClient, tools: List[Tool]):
        self.llm = llm_client
        self.tools = {t.name: t for t in tools}
    
    def execute(self, request: AgentRequest) -> AgentResponse:
        """执行Agent推理循环,输入结构化请求,输出结构化响应"""
        # ReAct循环实现
        messages = self._build_initial_messages(request)
        
        for step in range(request.max_steps or 10):
            response = self.llm.generate(messages)
            
            # 检查是否需要调用工具
            if self._needs_tool_call(response):
                tool_result = self._call_tool(response.tool_call)
                messages.append(self._build_tool_message(tool_result))
                continue
            
            # 结束循环,返回最终结果
            return AgentResponse(
                content=response.content,
                steps=step + 1,
                tokens_used=response.tokens_used
            )
        
        # 超出步数限制
        return AgentResponse.error("Max steps exceeded")

原则三:结构化日志替代print

# src/logging.py
import logging
import json
from datetime import datetime

def setup_logging():
    logging.basicConfig(
        level=logging.INFO,
        format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
    )

# 使用结构化日志
logger = logging.getLogger(__name__)

# ❌ 错误:print
print(f"User {user_id} requested: {query}")

# ✅ 正确:结构化日志
logger.info(
    "agent_request_received",
    extra={
        "user_id": user_id,
        "query": query,
        "trace_id": trace_id,
        "timestamp": datetime.utcnow().isoformat()
    }
)

2.3 从Notebook迁移的实操步骤

Azure Machine Learning团队总结了一套标准流程,值得借鉴:

第一步:清除非必要的代码——移除所有数据探索、可视化、调试相关的代码(如%matplotlib inlinesns.distplot()wandb.init())。

第二步:将代码重构为函数——把数据加载、模型训练、模型评估各自封装成独立函数。

第三步:将函数组织进src/目录下的对应模块中,并在main.py中调用。

第四步:创建对应的单元测试文件放在tests/下。


三、LLM智能体的工程化挑战与架构设计

3.1 核心矛盾:概率模型 vs 确定性业务

构建AI Agent时,最大的技术障碍在于:LLM本质上是基于概率的“下一个token预测模型”,而企业软件的核心价值在于确定性(Determinism)。

在一个自动报销审核场景中,规则引擎给出的结论必须是“通过”或“拒绝”,且逻辑可追溯。而LLM可能会因为提示词的微小扰动,今天说“通过”,明天说“拒绝”,或者编造出不存在的报销政策。

解决方案是混合架构:LLM负责感知与推理(意图识别、信息抽取),传统代码负责逻辑与执行(数据校验、权限控制、状态更新)。

3.2 Agent的ReAct循环设计

Agent系统的核心是ReAct(Reason + Act)循环:Agent对任务进行推理,决定一个动作(调用工具),执行该动作,观察结果,然后重复。

# src/agent/executor.py(续)
from src.agent.tools import Tool, ToolResult
from src.models.request import AgentRequest
from src.models.response import AgentResponse

class AgentExecutor:
    def _needs_tool_call(self, response) -> bool:
        """判断LLM响应是否包含工具调用请求"""
        return hasattr(response, 'tool_calls') and len(response.tool_calls) > 0
    
    def _call_tool(self, tool_call) -> ToolResult:
        """执行工具调用,带超时与错误处理"""
        tool_name = tool_call.name
        tool_args = tool_call.arguments
        
        if tool_name not in self.tools:
            return ToolResult.error(f"Unknown tool: {tool_name}")
        
        try:
            tool = self.tools[tool_name]
            result = tool.execute(**tool_args)
            return result
        except Exception as e:
            logger.error(f"Tool execution failed: {e}", exc_info=True)
            return ToolResult.error(str(e))

3.3 有界循环(Bounded Loop):防止无限循环与成本失控

生产环境中,Agent必须设置硬性预算限制,否则可能陷入无限循环,导致成本失控。推荐的预算限制包括:

  • 最大步骤数:10步
  • 最大Token数:8,000
  • 单工具调用最多尝试:3次
  • 最大运行时间:120秒
# src/agent/executor.py(续)
from datetime import datetime, timedelta

class AgentExecutor:
    def execute(self, request: AgentRequest) -> AgentResponse:
        start_time = datetime.utcnow()
        total_tokens = 0
        
        for step in range(request.max_steps or 10):
            # 检查时间预算
            if datetime.utcnow() - start_time > timedelta(seconds=120):
                return AgentResponse.error("Timeout exceeded")
            
            response = self.llm.generate(messages)
            total_tokens += response.tokens_used
            
            # 检查Token预算
            if total_tokens > 8000:
                return AgentResponse.error("Token budget exceeded")
            
            # ... 后续逻辑

四、配置管理与安全:Secrets不能写在.env里

4.1 分层配置策略

在Notebook里,os.environ['API_KEY'] = 'xxx'很爽,但在生产里这是灾难的源头。我们需要分层的配置管理:

配置类型 存储方式 示例
非敏感配置 K8s ConfigMap 或 YAML 模型路径、日志级别
敏感信息 K8s Secrets / 云厂商Secret Manager API密钥、数据库密码
环境差异化 环境变量注入 开发/测试/生产环境标识
# src/config.py
from pydantic import Field
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    # 从环境变量读取
    app_env: str = Field("development", env="APP_ENV")
    log_level: str = Field("INFO", env="LOG_LEVEL")
    
    # 敏感信息从Secrets读取
    openai_api_key: str = Field(..., env="OPENAI_API_KEY")
    model_name: str = Field("gpt-4o-mini", env="MODEL_NAME")
    
    # 非敏感配置带默认值
    max_agent_steps: int = Field(10, env="MAX_AGENT_STEPS")
    tool_timeout_seconds: int = Field(30, env="TOOL_TIMEOUT_SECONDS")
    
    class Config:
        env_file = ".env"
        env_file_encoding = "utf-8"

settings = Settings()

4.2 人机协同(Human-in-the-Loop)的安全护栏

对于高风险操作(如删除数据、修改权限、金额超过1000元的转账),Agent必须强制要求人工审批才能执行。

# src/agent/tools.py(示例)
from enum import Enum

class ActionLevel(Enum):
    AUTOMATE = "automate"   # 低风险,全自动
    AUGMENT = "augment"     # 中风险,需人工审批
    HUMAN_ONLY = "human"    # 高风险,Agent禁止执行

class Tool:
    def __init__(self, name: str, action_level: ActionLevel):
        self.name = name
        self.action_level = action_level
    
    def execute(self, **kwargs) -> ToolResult:
        if self.action_level == ActionLevel.HUMAN_ONLY:
            return ToolResult.error(
                "This action requires human approval and cannot be executed by Agent"
            )
        
        if self.action_level == ActionLevel.AUGMENT:
            # 挂起操作,等待人工审批凭证
            approval_token = kwargs.pop('approval_token', None)
            if not self._verify_approval(approval_token):
                return ToolResult.error("Human approval required")
        
        # 执行实际操作
        return self._do_execute(**kwargs)

五、容器化与部署:Docker不是终点,是起点

5.1 Dockerfile的最佳实践

选择基础镜像时,应使用python:3.11-slim而非alpine。原因很实际:NumPy、PyTorch等科学计算库在musl libc(alpine使用)上的编译与运行存在大量兼容性问题,可能导致随机的段错误。

# Dockerfile
# 第一阶段:构建依赖(多阶段构建)
FROM python:3.11-slim AS builder

WORKDIR /build

# 安装编译依赖
RUN apt-get update && apt-get install -y \
    gcc \
    gfortran \
    && rm -rf /var/lib/apt/lists/*

COPY requirements.txt .
RUN pip install --no-cache-dir --user -r requirements.txt

# 第二阶段:运行时镜像
FROM python:3.11-slim

WORKDIR /app

# 复制第一阶段安装的包
COPY --from=builder /root/.local /root/.local

# 复制源代码
COPY src/ ./src/
COPY main.py .
COPY pyproject.toml .

# 设置环境变量
ENV PYTHONPATH=/app
ENV PATH=/root/.local/bin:$PATH

# 健康检查
HEALTHCHECK --interval=30s --timeout=3s \
    CMD python -c "import requests; requests.get('http://localhost:8000/health')" || exit 1

# 启动服务
CMD ["uvicorn", "src.api.routes:app", "--host", "0.0.0.0", "--port", "8000"]

5.2 无状态设计:实例级扩展的基础

生产环境中的服务必须是无状态的——所有会话状态存储在外部(Redis、数据库),每个请求独立处理。这样新实例可以在3秒内承接流量,旧实例可以优雅退出。

# src/api/dependencies.py
from redis import Redis
from src.agent.executor import AgentExecutor

def get_redis_client() -> Redis:
    """Redis客户端单例,用于存储会话状态"""
    return Redis.from_url(settings.redis_url)

def get_agent_executor() -> AgentExecutor:
    """Agent执行器依赖注入"""
    llm_client = LLMClient(api_key=settings.openai_api_key)
    tools = load_tools()
    return AgentExecutor(llm_client, tools)

六、可观测性:给Agent装上“黑匣子”

生产环境中,如果Agent出错,你不能只看到最终回复是错的——你需要知道它为什么错。传统日志系统完全失效,因为Agent包含多轮思考和多次API调用。

6.1 全链路追踪

每个用户请求生成唯一的Trace ID,记录从输入到输出的完整生命周期:

# src/utils/tracing.py
import uuid
from contextvars import ContextVar

trace_id_var: ContextVar[str] = ContextVar("trace_id", default="")

def get_trace_id() -> str:
    return trace_id_var.get()

def set_trace_id(trace_id: str = None) -> str:
    if trace_id is None:
        trace_id = str(uuid.uuid4())
    trace_id_var.set(trace_id)
    return trace_id

# src/api/routes.py
from fastapi import Request
from src.utils.tracing import set_trace_id, get_trace_id

@router.post("/agent")
async def agent_endpoint(request: Request, body: AgentRequest):
    trace_id = set_trace_id(body.trace_id)
    logger.info("agent_request_started", extra={"trace_id": trace_id, "query": body.query})
    
    try:
        response = executor.execute(body)
        logger.info("agent_request_completed", extra={
            "trace_id": trace_id,
            "steps": response.steps,
            "tokens": response.tokens_used
        })
        return response
    except Exception as e:
        logger.error("agent_request_failed", extra={"trace_id": trace_id, "error": str(e)})
        raise HTTPException(500, "Internal error")

6.2 关键指标

需要暴露给Prometheus的核心指标:

  • agent_requests_total:请求总数(按状态分)
  • agent_latency_seconds:延迟直方图(P50/P90/P99)
  • agent_tool_calls_total:工具调用次数(按工具名分)
  • agent_tokens_consumed_total:Token消耗总量
  • agent_fallback_triggered_total:兜底策略触发次数

七、测试与LLMOps:Agent也是代码

7.1 测试金字塔

Agent系统的测试需要分层策略:

单元测试:测试单个组件,如意图分类器、工具调用解析。

# tests/test_agent/test_executor.py
import pytest
from src.agent.executor import AgentExecutor
from src.models.request import AgentRequest

def test_agent_executor_basic():
    """测试Agent执行器基础功能"""
    executor = create_test_executor()
    request = AgentRequest(query="今天天气怎么样?")
    response = executor.execute(request)
    
    assert response.content is not None
    assert response.steps > 0

集成测试(E2E):使用LLM-as-a-Judge评估输出质量。准备一组标准测试用例(Golden Dataset),用另一个高能力LLM作为裁判,评估Agent的输出是否符合预期。

混沌工程:模拟外部服务故障(如LLM API超时、数据库宕机),测试Agent的容错能力。

7.2 灰度发布策略

生产部署应分阶段推进:

  1. 内部影子模式:部署Agent只对复制流量开放,人工每日检查日志
  2. 内部金丝雀:小范围内部专家用户开放,收集直接反馈
  3. 外部金丝雀(1%):对1%外部用户开放,监控成本与错误率
  4. 分阶段推广(10%→100%):逐步增加流量,每步监控仪表盘

总结:治理决定性能

从Jupyter Notebook到生产环境的跨越,本质上不是技术栈的升级,而是思维方式的转变

  • 从“实验可复现”到“系统可观测”:不是模型精度优先,而是错误可定位、行为可追踪
  • 从“一次性运行”到“持续演进”:CI/CD流水线、灰度发布、A/B测试是标配
  • 从“模型即全部”到“系统即整体”:数据管道、特征服务、监控告警、兜底策略缺一不可

正如Oracle AI高级首席工程师Ashok Prakash所言:生产Agent的真正衡量标准是它对安全边界的服从,而非其智力。治理结构决定了系统的可靠性——把工程精力投入在外围框架上,远比追求模型智力上限更有价值。

希望本文能帮你迈出从Notebook到生产的关键一步。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐