Stone Soup AI 是 2024 年 AI 工程实践里一个很有意思的项目代号,它借了“石头汤”这个寓言的壳:一锅汤开始只放一块石头和水,路过的人各自加入一点食材,最后煮成一锅所有人都能分享的浓汤。放到 AI 应用开发里,这条思路其实非常实用——很多项目之所以卡在原型阶段,不是因为模型不够强,而是因为一开始就想把检索、Agent、多轮对话、权限、部署全部做完,结果锅太大,连水都没烧开。Stone Soup AI 的做法反过来:先有一个最小的模型接口,让它能跑起来、能返回结果,再根据业务需要逐步加入向量检索、工具调用、上下文记忆和容器化部署。这篇文章就围绕 Stone Soup AI 的工程路径,从零搭一个本地 LLM 服务,再扩展成检索增强的多组件应用,最后完成 Docker 部署,并给出生产环境最容易踩的坑和排查链路。
读完这篇文章,你会掌握一套可以直接复用的 AI 应用骨架:怎么封装模型调用、怎么设计配置文件、怎么把 RAG 和工具调用接进同一个请求链路、怎么避免多进程加载模型把显存打爆。适合刚开始接触 AI 应用开发、准备把模型部署成 API 服务、或者在现有项目里做 Agent 和检索增强的开发者。
1. Stone Soup AI 的工程思想:先煮石头,再放食材
1.1 石头汤原意和 AI 工程模型的映射
石头汤的寓言核心不是“占便宜”,而是“协作和渐进”。有人拿出一块石头,有人提供水,其他村民觉得反正已经有人开火了,就陆续带来胡萝卜、土豆、肉和盐。最终大家分到的不只是一锅汤,而是一种共同完成一件事的方法。
在 AI 工程里,这块“石头”可以是一个最小的模型推理服务,一锅“水”是基础 HTTP API 和配置系统。这里先把对应关系列出来。
| 寓言元素 | AI 工程对应物 | 具体落点 |
|---|---|---|
| 石头 | 最小可用模型 | 本地部署的 LLM、Embedding 模型或调用外部模型 API 的统一封装 |
| 水 | 基础工程骨架 | FastAPI 服务、配置读取、请求响应模型、日志 |
| 村民 | 开发者和业务方 | 负责加入数据、评估、工具、权限、监控的人 |
| 食材 | 业务能力 | 私有知识库、工具调用、Agent 编排、缓存、限流 |
| 锅 | 项目结构和编排层 | 让多个组件按统一协议协作的服务层 |
这个映射可以帮助团队先对齐一个原则:第一版只解决“模型能不能稳定响应”,不要急着做完整产品。当接口稳定后,后续所有能力都是往这口锅里加料。
1.2 为什么 2024 年这个思路重新被提起
大模型本身的能力在过去两年提升很快,但 AI 应用开发的复杂度并没有因此下降。单模型已经很难覆盖真实业务:模型不知道企业私有数据、不能实时查库、记不住多轮上下文、也容易产生幻觉。于是 AI 工程实践里出现了 RAG、Agent、工具调用、评测体系、模型网关等一系列组件层。
组件越多,项目越容易失控。Stone Soup AI 这种“从最小闭环开始”的思路正好适合应对这种失控:先跑通一个 API,再逐步接入组件,每加一个食材都要能验证它确实让汤变好喝了,而不是为了架构完整而堆代码。
这里要说清楚,Stone Soup AI 不适合所有项目。比如已经有完整 SDK 和成熟框架支撑的企业级平台,团队可以直接在框架上开发,不需要从石头开始。它更适合小团队、原型验证、内部工具和需要快速看到效果的 AI 应用。
| 适合场景 | 不适合场景 |
|---|---|
| 从零搭建 MVP,需要快速验证模型能力 | 已有成熟 AI 平台,需要直接对接其 SDK |
| 要部署本地模型,又不想引入重型框架 | 对响应延迟有极高要求的实时通信系统 |
| 团队想理解 LLM 调用、RAG、Agent 全链路 | 所有组件都依赖外部服务,不需要本地模型 |
| 需要逐步替换模型、对比效果 | 业务规则完全固定,模板化即可满足 |
1.3 初始的“石头”到底选什么
Stone Soup AI 的第一版建议只包含四样东西:一个可本地运行的 LLM、一个 HTTP 服务框架、一个配置文件、一个健康检查接口。模型大小根据硬件条件决定,不建议第一版就追求大模型。
这块石头的作用不是做到最优,而是让团队尽快看到完整链路:请求进来,模型生成,结果返回。只有这条链路通了,后续加入检索、工具调用、用户管理才有意义。
注意:第一版不要同时接多个模型、不要写复杂 prompt 模板、不要做用户体系。Stone Soup AI 的核心是渐进式构建,任何功能都必须等到最小链路稳定后再加入。
2. 环境准备:跑通最小项目需要哪些依赖
2.1 运行环境与版本建议
实际项目里,不同的显卡、驱动、PyTorch 版本和模型权重会直接影响是否能跑通。这里按常见情况给出建议,落地前要根据自己的硬件调整。
| 项目 | 推荐值 | 说明 |
|---|---|---|
| 操作系统 | Ubuntu 20.04 及以上 | 生产服务器常见选择,容器化部署更方便 |
| Python | 3.10 或 3.11 | 新版 Transformers 对旧版本支持逐渐降低 |
| PyTorch | 2.1 以上 | 根据 CUDA 版本安装对应轮子 |
| 显卡驱动 | 支持 CUDA 11.8 或 12.1 | 使用nvidia-smi查看 |
| 显存 | 学习环境 8GB 以上 | 生产环境建议 16GB 以上或使用量化 |
| Docker | 24.0 以上 | 可选手动部署时不需要 |
如果本机没有 GPU,也可以用 CPU 跑一个小模型,比如 1B 参数级别的量化模型,速度慢但能验证链路。不要因为在学习环境跑不动大模型就跳过工程骨架。
2.2 初始化项目结构和虚拟环境
创建一个stone-soup-ai目录,然后按下面结构组织代码。
stone-soup-ai/ ├── app/ │ ├── __init__.py │ ├── main.py │ ├── config.yaml │ ├── services/ │ │ ├── __init__.py │ │ ├── llm.py │ │ ├── retriever.py │ │ └── orchestrator.py │ └── schemas.py ├── scripts/ │ └── check_env.py ├── data/ │ └── docs/ ├── Dockerfile └── docker-compose.yml这个目录结构把代码、配置、数据、部署文件分开。app/services里每个模块只负责一件事:llm.py负责模型加载和生成,retriever.py负责向量检索,orchestrator.py负责把多个组件串起来。
2.3 安装依赖
先用虚拟环境隔离项目依赖。
cd stone-soup-ai python -m venv .venv source .venv/bin/activate创建一个requirements.txt,内容大致如下。
fastapi==0.115.5 uvicorn[standard]==0.32.1 pydantic==2.9.2 pyyaml==6.0.2 transformers==4.46.2 torch==2.5.1 sentence-transformers==3.1.1 faiss-cpu==1.9.0.post1安装命令:
pip install -r requirements.txt实际项目里,torch的安装方式要根据 CUDA 版本调整。如果使用 CPU 环境,可以在 PyTorch 官网选择 CPU 版本安装,不要直接装默认全量包,否则可能白白占用几个 GB。
2.4 环境检查清单
在写业务代码之前,先跑一个环境检查脚本,确认依赖能正常导入。
# scripts/check_env.py import sys def main(): print("Python:", sys.version) try: import torch print("PyTorch:", torch.__version__) print("CUDA available:", torch.cuda.is_available()) if torch.cuda.is_available(): print("GPU:", torch.cuda.get_device_name(0)) except ImportError as e: print("Missing torch:", e) try: import transformers print("Transformers:", transformers.__version__) except ImportError as e: print("Missing transformers:", e) try: import fastapi print("FastAPI:", fastapi.__version__) except ImportError as e: print("Missing fastapi:", e) if __name__ == "__main__": main()运行:
python scripts/check_env.py预期看到 Python 版本、PyTorch 版本和 Transformers 版本正常输出版号。如果CUDA available: False,说明当前安装的 PyTorch 不支持 GPU,后续加载模型会走 CPU。不要跳过这个检查,否则模型加载阶段很难排查是代码问题还是环境问题。
3. 最小闭环:先让模型服务返回文字
3.1 为什么第一步是做 API 服务而不是聊天页面
Stone Soup AI 的第一块石头应该是“稳定的模型服务接口”,而不是前端页面。原因是:所有后续组件,包括 RAG、Agent、日志、监控,都要面对同一个服务接口。只要这个接口稳定,后面替换模型、增加工具、接入前端都只是加料。
这里用 FastAPI 封装一个简单的 LLM 服务,让客户端通过 HTTP 请求拿到模型生成的文本。
3.2 配置文件先行
在app/config.yaml里写入最小配置。
model: name: "Qwen/Qwen2.5-1.5B-Instruct" device: "auto" dtype: "auto" max_new_tokens: 512 temperature: 0.7 top_p: 0.9 repetition_penalty: 1.05 server: host: "0.0.0.0" port: 8000 max_request_length: 4096配置为什么要外置?因为模型名称、设备、采样参数会随着测试调整,如果写死在代码里,每次改参数都要改代码。把配置独立出来,业务代码不感知模型细节,后面换模型时只需要改配置文件。
3.3 封装模型服务
在app/services/llm.py中编写模型加载和生成逻辑。
import yaml from pathlib import Path from typing import List, Optional import torch from transformers import AutoModelForCausalLM, AutoTokenizer CONFIG_PATH = Path(__file__).resolve().parent.parent / "config.yaml" class LLMService: def __init__(self, config_path: str = str(CONFIG_PATH)): with open(config_path, "r", encoding="utf-8") as f: self.config = yaml.safe_load(f) model_config = self.config["model"] self.tokenizer = AutoTokenizer.from_pretrained( model_config["name"], trust_remote_code=True, ) self.model = AutoModelForCausalLM.from_pretrained( model_config["name"], device_map=model_config.get("device", "auto"), torch_dtype=model_config.get("dtype", "auto"), trust_remote_code=True, ) self.model.eval() def generate( self, prompt: str, history: Optional[List[dict]] = None, ) -> str: messages = [] for item in history or []: messages.append({"role": item.get("role", "user"), "content": item.get("content", "")}) messages.append({"role": "user", "content": prompt}) text = self.tokenizer.apply_chat_template( messages, tokenize=False, add_generation_prompt=True, ) inputs = self.tokenizer(text, return_tensors="pt").to(self.model.device) with torch.no_grad(): outputs = self.model.generate( **inputs, max_new_tokens=self.config["model"].get("max_new_tokens", 512), temperature=self.config["model"].get("temperature", 0.7), top_p=self.config["model"].get("top_p", 0.9), repetition_penalty=self.config["model"].get("repetition_penalty", 1.05), do_sample=True, ) new_tokens = outputs[0][inputs["input_ids"].shape[1]:] return self.tokenizer.decode(new_tokens, skip_special_tokens=True)关键点有三个:
第一,apply_chat_template会根据模型对应的模板把多轮消息拼接成模型需要的格式。不同模型的 prompt 格式不一样,手动拼容易出错,这一步建议使用模型自带的 tokenizer。
第二,model.generate在torch.no_grad()下运行,避免保存不必要的梯度,减少显存占用。
第三,生成结束后只截取新增 token 对应的部分,把输入 prompt 去掉,返回干净结果。
3.4 编写 FastAPI 入口
在app/schemas.py中定义请求和响应结构。
from typing import List, Optional from pydantic import BaseModel, Field class ChatMessage(BaseModel): role: str = Field(default="user", description="消息角色") content: str = Field(..., description="消息内容") class ChatRequest(BaseModel): prompt: str = Field(..., min_length=1, max_length=4096) history: List[ChatMessage] = Field(default_factory=list) class ChatResponse(BaseModel): reply: str prompt_tokens: Optional[int] = 0 generated_tokens: Optional[int] = 0在app/main.py中创建应用。
from fastapi import FastAPI from app.schemas import ChatRequest, ChatResponse from app.services.llm import LLMService app = FastAPI(title="Stone Soup AI", version="0.1.0") llm_service = LLMService() @app.get("/healthz") def healthz(): return {"status": "ok"} @app.post("/v1/chat", response_model=ChatResponse) def chat(req: ChatRequest): reply = llm_service.generate(req.prompt, history=[m.model_dump() for m in req.history]) return ChatResponse(reply=reply)这个接口故意设计得很小:一个健康检查、一个聊天接口。后续加入检索、工具调用时,也只在这个接口的请求模型里扩展字段,前端和调用方不需要感知内部变化。
3.5 启动和验证
启动服务。
uvicorn app.main:app --host 0.0.0.0 --port 8000日志里应该出现 FastAPI 的启动信息,模型加载可能需要几十秒到几分钟。之后用 curl 验证。
curl -X POST http://127.0.0.1:8000/v1/chat \ -H "Content-Type: application/json" \ -d '{"prompt": "用一句话解释什么是向量数据库"}'预期返回 JSON:
{ "reply": "向量数据库是一种专门存储和检索高维向量的数据库,常用于语义搜索和相似度匹配。", "prompt_tokens": 0, "generated_tokens": 0 }3.6 第一个常见坑:模型加载慢和显存不足
第一次加载模型时,如果网络不通或 Hugging Face 仓库下载中断,会长时间卡住。实际项目建议先把模型权重下载到本地目录,再通过本地路径加载。
如果日志里出现CUDA out of memory,可以按顺序做三件事:
- 降低
max_new_tokens,默认 512 改成 128 或 64。 - 使用 8 位或 4 位量化,在
from_pretrained中加load_in_8bit=True或load_in_4bit=True。 - 换更小的模型。
Stone Soup AI 的好处这时候就体现出来了:石头足够小,怎么煮都行。
4. 往锅里加食材:检索、记忆和工具调用
4.1 单模型为什么不够
一个只有模型生成接口的系统,本质上是一个“没有记忆但会说话的函数”。它不知道业务库里的私有数据,也没有持久记忆。要让汤真正有味道,需要加入两种食材:外部知识和可执行工具。
这一节按两条线扩展:
- 检索增强:把文档向量化并保存到向量库,在生成前先检索相关片段。
- 工具调用:让模型可以请求执行一个函数,比如查天气、查订单、调数据库接口。
两条线可以同时存在,Stone Soup AI 的锅要能放下它们。
4.2 给汤锅加入检索:基于 FAISS 的本地向量库
先准备几段文档放到data/docs/下,比如product.txt,内容是产品说明。然后写一个retriever.py,用sentence-transformers生成句子向量,用 FAISS 做相似度检索。
from pathlib import Path from typing import List from sentence_transformers import SentenceTransformer import faiss import numpy as np class Retriever: def __init__(self, docs_dir: str = "data/docs", embedding_model: str = "BAAI/bge-small-zh-v1.5"): self.encoder = SentenceTransformer(embedding_model) self.docs = [] for path in sorted(Path(docs_dir).glob("*.txt")): chunks = self._split_text(path.read_text(encoding="utf-8"), chunk_size=200) self.docs.extend(chunks) if not self.docs: raise ValueError("No documents found in docs_dir") embeddings = self.encoder.encode(self.docs, normalize_embeddings=True) dim = embeddings.shape[1] self.index = faiss.IndexFlatIP(dim) self.index.add(np.array(embeddings)) def _split_text(self, text: str, chunk_size: int) -> List[str]: paragraphs = text.split("\n") chunks = [] buf = "" for p in paragraphs: if len(buf) + len(p) + 1 > chunk_size: if buf: chunks.append(buf.strip()) buf = p else: buf += "\n" + p if buf: chunks.append(buf.strip()) return chunks def search(self, query: str, top_k: int = 3) -> List[str]: query_vec = self.encoder.encode([query], normalize_embeddings=True) scores, indices = self.index.search(np.array(query_vec), top_k) return [self.docs[i] for i in indices[0]]这里使用IndexFlatIP是因为 embedding 已经做了归一化,内积等于余弦相似度。chunk_size=200控制文档切分长度,实际项目中需要根据文档结构调整,过小会丢失上下文,过大会浪费模型的上下文窗口并降低检索精度。
4.3 加入轻量 Agent:让模型能调用工具
工具调用的完整链路比较复杂,但最小实现可以这样理解:
- 系统给模型一个工具说明。
- 模型生成一段 JSON,表示要调用哪个函数和参数。
- 应用解析 JSON,执行函数,把结果返回给模型再生成最终回答。
在app/services/tools.py里注册一个最简单的查询工具。
import json from typing import Callable, Dict, Any tools: Dict[str, Dict[str, Any]] = {} def register_tool(name: str, description: str, parameters: dict, func: Callable): tools[name] = { "description": description, "parameters": parameters, "func": func, } def get_tool_schema(): return { name: { "description": t["description"], "parameters": t["parameters"], } for name, t in tools.items() } def execute_tool(name: str, arguments: dict) -> str: if name not in tools: return json.dumps({"error": f"tool {name} not found"}) try: result = tools[name]["func"](**arguments) return json.dumps(result, ensure_ascii=False) except Exception as e: return json.dumps({"error": str(e)}, ensure_ascii=False)注册一个查询方法:
def fake_query_balance(user_id: str): return {"user_id": user_id, "balance": 99.50} register_tool( name="query_balance", description="查询用户余额", parameters={"type": "object", "properties": {"user_id": {"type": "string"}}}, func=fake_query_balance, )注意:这个示例只用于演示工具注册和调用思路,实际接入订单、支付等系统时要严格控制权限和参数校验。
4.4 把检索和工具调用织进同一请求链路
在app/services/orchestrator.py里把LLMService、Retriever和工具执行器串起来。主流程是:用户请求 -> 检索相关文档 -> 组装 system prompt -> 模型生成第一轮 -> 如果结果包含工具调用 JSON 则执行工具 -> 拼接工具结果 -> 模型生成最终回答。
import json from app.services.llm import LLMService from app.services.retriever import Retriever from app.services.tools import get_tool_schema, execute_tool class Orchestrator: def __init__(self, llm: LLMService, retriever: Retriever): self.llm = llm self.retriever = retriever def run(self, prompt: str, history=None): context = "\n".join(self.retriever.search(prompt, top_k=3)) system_prompt = ( "你是一个 AI 助手。请使用以下知识回答用户问题," "如果知识不足以回答,可以说不知道。\n\n" f"知识片段:\n{context}\n\n" "如果用户请求与工具相关,请输出 JSON:" '{"tool": "工具名", "arguments": {...}}' ) full_prompt = f"{system_prompt}\n\n用户:{prompt}" first = self.llm.generate(full_prompt, history=[]) try: action = json.loads(first) if isinstance(action, dict) and "tool" in action: tool_result = execute_tool(action["tool"], action.get("arguments", {})) second_prompt = ( f"工具执行结果:{tool_result}\n" f"请根据结果回答用户问题:{prompt}" ) return self.llm.generate(second_prompt, history=history or []) except json.JSONDecodeError: pass return first这段代码的关键不是实现一个完整的 Agent 框架,而是展示最小可用的编排逻辑。模型可能返回普通文本,也可能返回工具 JSON,通过json.loads尝试解析来区分。
4.5 核心参数速查
Stone Soup AI 扩展后会同时遇到模型参数、检索参数和工具调用超时参数。这里用一张表收拢最常见的调整项。
| 参数 | 位置 | 常见值 | 调大的影响 | 调小的影响 |
|---|---|---|---|---|
| temperature | LLM 配置 | 0.7 | 回答更随机,更容易发散 | 回答更稳定,可能更机械 |
| top_p | LLM 配置 | 0.9 | 候选词更多 | 候选词更集中,可能更保守 |
| max_new_tokens | LLM 配置 | 512 | 能生成长文本,耗时增加 | 回答可能被截断 |
| chunk_size | Retriever 配置 | 200 | 上下文更完整,检索精度可能下降 | 检索更精准,上下文可能不足 |
| top_k | Retriever 查询 | 3 | 给模型更多参考片段 | 上下文更简练,可能漏关键信息 |
| 请求超时 | 代理层 | 30s-60s | 长时间排队时请求不失败 | 大模型慢生成时容易超时 |
实际项目里,这些参数不是越大越好,也不是越小越好。修改时一次只改一个变量,并且要留日志记录“哪一版参数产生了哪一份输出”,否则很难判断效果差异来自哪里。
5. 部署验证:从本地调试到容器化发布
5.1 为什么要容器化
本地能跑通和服务器能稳定运行是两件事。模型推理服务依赖的包很多,版本敏感,如果每台机器手动配,很容易出现本地能跑、生产环境报torch或transformers版本不一致的情况。用 Docker 可以把这个锅完整打包,环境差异被隔离在容器里。
以下是一个基础 Dockerfile,适合 GPU 部署场景。
FROM pytorch/pytorch:2.5.1-cuda12.1-cudnn9-runtime WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY app ./app COPY data ./data EXPOSE 8000 CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]5.2 用 docker-compose 管理服务和数据
如果还需要单独启动向量库或者外部工具服务,可以用docker-compose.yml。如果只是单服务,也可以直接docker build。
version: "3.9" services: stone-soup: build: . ports: - "8000:8000" volumes: - ./data:/app/data - ~/.cache/huggingface:/root/.cache/huggingface environment: - MODEL_NAME=Qwen/Qwen2.5-1.5B-Instruct - MAX_NEW_TOKENS=256 - TEMPERATURE=0.7 deploy: resources: reservations: devices: - driver: nvidia count: 1 capabilities: [gpu] healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/healthz"] interval: 30s timeout: 5s retries: 3把模型权重挂到宿主机缓存目录,可以避免每次重建容器都重新下载模型。健康检查使用/healthz接口,这样调度平台可以自动发现服务不可用并重启容器。
5.3 学习环境与生产环境差异
| 关注点 | 学习环境 | 生产环境 |
|---|---|---|
| 模型注册 | 代码里写死 | 通过环境变量或配置中心注入 |
| 密钥 | 无或临时 | 使用 Secret 管理,不落仓库 |
| 日志 | 终端输出 | JSON 结构化日志,转到采集系统 |
| 监控 | 不需要 | 显存、GPU 利用率、请求延迟、错误率 |
| 并发 | 单请求 | 队列、限流、超时、自动扩缩容 |
| 回滚 | 删掉重新跑 | 镜像版本管理,保留上一可用版本 |
| 数据 | 本地文档 | 数据源权限控制、备份、脱敏 |
5.4 服务端的并发注意点
模型加载到内存后,多个请求不能直接无限制并发。常见做法有两种:
第一种是单进程 + 异步队列:由 FastAPI 的请求处理函数把任务提交到队列,模型单线程生成,响应延迟增加但显存可控。
第二种是多副本,每个副本加载一份模型,前端用负载均衡分发。这时要注意uvicorn的--workers参数不能随便调大,否则每个 worker 都会复制一份模型,显存直接翻倍。
推荐在模型服务里使用全局单例,确保模型只加载一次。已经实现的LLMService是在模块导入时创建的,FastAPI 多 worker 下每个进程各有一份,这符合“按进程隔离模型”的设计。
注意:模型服务不是普通 Web 服务,不能像高并发接口那样堆 worker 数量。显存是硬约束,正确做法是控制单副本并发数,而不是盲目增加进程。
5.5 发布前检查清单
在把 Stone Soup AI 推上线之前,按下面清单逐项检查。
- 模型权重所在路径是否可读,磁盘剩余空间是否足够。
- 配置项是否全部通过环境变量注入,默认值是否安全。
/healthz是否返回正常,健康检查间隔是否覆盖模型重启时间。- 单请求最大输入长度是否有限制,超大请求是否会被拒。
- 日志是否包含请求 ID、耗时、错误堆栈。
- 显存是否满足最大并发场景,超卖是否会导致 OOM。
- 是否有容量预估:单副本支持多少 QPS,延迟 P95 是多少。
- 镜像是否有版本号,能否回滚到上一个可用版本。
6. 常见问题与排查链路
6.1 请求超时或返回空回复
现象:接口偶尔返回 200 但reply为空,或者直接 504 超时。
可能原因依次排查:
- 模型还在加载中,请求打到了未就绪的服务。检查
/healthz是否返回ok。 max_new_tokens太小,模型生成了空字符串。改为 256 再试。- 显卡显存不足,生成过程报
CUDA out of memory被日志丢弃。运行nvidia-smi看显存状态。 - 多个请求同时进入,模型推理产生排队,导致应用层超时。
检查命令:
nvidia-smi docker logs --tail 200 <container_name> curl http://127.0.0.1:8000/healthz6.2 回答质量明显偏差
现象:模型回答和问题无关,或者复读知识片段。
先从 Prompt 层排查:system prompt 是否明确告诉模型“知识不足时直接说不知道”;是否把检索结果原样塞进了上下文;temperature是否设置得过高。
再从检索层排查:top_k是否太小,chunk_size是否破坏了语义,embedding 模型是否和业务语言匹配。中文场景使用bge-small-zh-v1.5这类中文模型,比直接使用通用英文模型效果更稳定,但不是所有环境都支持,落地前要实测。
6.3 显存持续上涨
现象:服务运行时间越长,显存占用越高,最终崩溃。
常见原因是模型推理过程中缓存没有释放,或者请求处理中创建了新的张量没有被及时回收。排查步骤如下:
- 检查代码里是否在每次请求时重新调用
AutoModelForCausalLM.from_pretrained,绝对不能这样做,模型必须单例。 - 检查
torch.no_grad()是否遗漏。 - 检查
History列表是否无限增长,长对话上下文可能让 prompt 长度持续增加,导致推理显存自然增长。
解决方案是:限制历史轮数;对长对话做摘要压缩;在服务层控制最大并发数。
6.4 Agent 工具调用报错
现象:模型输出的 JSON 无法解析,或者工具名称不存在。
让模型输出严格 JSON 本身就不稳定。最小实现里,可以通过在 system prompt 中明确 JSON 示例,并在解析失败时自动降级为普通文本回答。工具执行结果应作为一个独立消息重新交给模型,而不是直接拼接在问题里,否则模型容易分不清哪段是用户输入、哪段是工具输出。
6.5 统一排查链路
所有问题都建议按下面的链路排查,不要一开始就怀疑模型能力。
| 排查层级 | 检查项 | 常见结论 |
|---|---|---|
| 输入层 | 请求参数是否合法、prompt 是否为空、上下文长度 | 请求格式错误 |
| 代码路径 | 是否走了预期分支、是否直接抛异常 | 逻辑分支遗漏 |
| 配置层 | model_name、device、max_new_tokens 是否生效 | 配置没读或路径错误 |
| 资源层 | GPU 显存、CPU 内存、磁盘是否充足 | 资源不足导致 OOM |
| 日志层 | 是否有完整异常堆栈、请求 ID 是否能串联 | 日志缺失导致无法定位 |
| 版本层 | torch、transformers、模型权重是否匹配 | 版本不兼容 |
7. 最佳实践:让这一锅汤可以复制到多个项目
7.1 保持组件接口稳定
Stone Soup AI 的工程骨架要能复制,前提是组件之间通过稳定接口通信。LLMService只暴露generate(prompt, history) -> str,Retriever只暴露search(query, top_k) -> List[str],工具注册表只依赖一个函数签名。未来无论是换模型、换向量库,还是接外部 Agent 框架,都只影响对应模块内部,不影响整条链路。
7.2 Prompt 和上下文管理要版本化
Prompt 不是临时写在代码里的字符串。建议把 system prompt 放到独立配置文件或单独目录,带有版本号。变更 prompt 后要记录变化内容,否则模型效果变化无法归因。
7.3 从原型到生产的三个分阶段路线
第一阶段,只跑通模型 API 和健康检查,目标是不超过一周。
第二阶段,加入知识检索和上下文记忆,目标是在自己的业务数据上得到可接受回答。
第三阶段,加入 Agent 工具调用、权限校验、监控告警,目标是把服务交给真实用户使用。
不要试图跳阶段。Stone Soup AI 的核心是每次只加一个食材,然后确认它的味道。
7.4 可复用的落地清单
- 配置独立于代码,至少支持环境变量覆盖。
- 模型实例全局唯一,不在运行时重复加载。
- 所有外部调用都有超时时间。
- 日志携带请求 ID 和耗时。
- 请求有最大长度限制,防止超大输入拖垮服务。
- 模型生成参数有固定默认值,变更参数走配置。
- 检索结果要限制条数,避免塞爆上下文。
- 工具调用必须有参数校验,不能直接执行未经校验的 JSON。
- 健康检查要在模型加载完成后才返回
ok。 - 每次发布保留上一个镜像版本,便于回滚。
Stone Soup AI 这个名字提醒开发者:真正复杂的不是第一块石头,而是后面不断加入的食材如何不把汤煮坏。只要最小链路是干净的,后续的检索、Agent、部署和监控都可以按同一套工程方式一步步加进去。下一步你可以把这块石头换成更强的新模型,也可以把小型工具注册表替换成成熟的 Agent 框架,前提是保持这个骨架的接口稳定和可观测性。对刚开始接触 AI 应用开发的读者,建议先照着最小 API 服务完整跑一遍,再决定是否加入向量检索和工具调用,顺序反过来很容易在组件调试中迷失。