在实际的 AI 大模型应用开发中,真正难的不是调通一次模型接口,而是把 LLM、RAG、Agent、MCP、工作流、工具调用这一整套能力有机地串起来。很多资料只讲单一技术点,例如讲 RAG 就只讲向量库,讲 Agent 就只讲 ReAct 循环,结果看完以后仍然不知道怎么组织一个完整的后端服务。这篇实战全家桶从零开始搭建一套可运行的 AI 应用骨架,覆盖大模型接入、检索增强、函数调用、Agent 循环、MCP 协议接入和固定工作流编排,并且同时给出 FastAPI 与 Spring Boot 两套实现。目标读者是具备基础后端开发经验、正在从“调用模型接口”走向“搭建 AI 应用服务”的开发者。
这套骨架完成后,你会得到一组统一风格的 HTTP 接口,包括 /api/chat、/api/rag、/api/tool、/api/agent、/api/workflow、/api/mcp/invoke。每个接口对应一个能力模块,模块之间又通过共享的消息结构和执行上下文串联。先跑通最小闭环,再逐步替换成生产组件,是这一篇的核心学习路线。
1. 先拆解这套全家桶:每个模块到底解决什么问题
1.1 LLM、RAG、Agent、工具调用、MCP、工作流的定位
先给六个模块各下一个容易理解的定义。
LLM 接入是这个全家桶的地基。它负责把用户输入发送给大模型,拿回模型生成的文本。这个概念本身不复杂,但工程落地时需要考虑模型地址、密钥、超时、重试、流式输出和上下文管理等细节。本文先做非流式的最小实现,保证流程可追踪。
RAG(检索增强生成)解决的是知识来源问题。通用大模型只会回答训练时见过的内容,无法保证包含企业内部文档、产品手册或最新资料。RAG 的做法是先把文档切分成片段、变成向量,用户提问时先检索相关片段,再把片段作为上下文和问题一起交给模型生成回答。这样回答内容就有外部知识支撑。
工具调用(Function Calling / Tool Calling)解决的是行动能力问题。模型的强项是语言生成,但不会真正查询天气、查数据库、发起订单。工具调用的机制是:程序把可用的工具用结构化描述告诉模型,模型在回答时输出一个“调用意图”,程序解析这个意图并真正执行工具代码,再把执行结果回传给模型继续生成。模型不负责执行,只负责做决策。
Agent 解决的是多步推理问题。单次工具调用只能完成一步操作,Agent 则让模型进入循环:理解问题、决定调用哪个工具、观察工具结果、再决定下一步,直到完成任务或达到最大轮数。可以简单地把 Agent 看作工具调用循环的外层控制器。
MCP(Model Context Protocol,模型上下文协议)解决的是工具接入标准化问题。如果每个工具都要自己定义一套调用格式,工具一多就会失控。MCP 是一个开放协议,它把“工具是什么、参数怎么传、结果怎么返回”统一成约定格式,让大模型应用通过同一套客户端访问不同服务端提供的工具、数据源和 Prompt 资源。
工作流(Workflow)解决的是流程固定化问题。Agent 适合动态决策,但有些业务步骤是确定的,例如“先判断意图,再查知识库,最后生成回答”。工作流把这类固定步骤编排成可重复执行的流程,每一步的输入输出都明确,便于调试、记录和成本控制。
这六个模块不是并列关系,而是从下到上的能力分层。模型调用是最底层,RAG 与工具调用是能力补充,Agent 是动态编排,工作流是固定编排,MCP 是工具接入的统一通道。
1.2 模块之间如何串联成一次完整请求
假设用户问:“根据公司最新运维手册,帮我查一下服务器磁盘告警的处理步骤,然后通过企业机器人把摘要发到值班群。”
一次完整请求的流转是:
- 工作流入口接收请求,先做意图识别,判断这是一个需要 RAG 和工具调用的复杂任务。
- RAG 模块根据“磁盘告警、运维手册”检索本地知识库,取出相关片段。
- 模型结合检索片段判断还需要调用企业机器人工具。
- Agent 循环执行工具调用,把发送结果作为中间状态继续交给模型。
- 最终模型汇总知识库内容和工具执行结果,生成完整回答。
在这个链路里,LLM 贯穿始终,RAG 提供知识,工具调用提供行动,Agent 控制循环,工作流定义步骤,MCP 统一工具接入。每层只负责一件事,上层不关心下层内部实现。
1.3 为什么同时提供 FastAPI 和 Spring Boot 双版本
双版本不是为了展示同一个功能写两遍,而是对应两类实际工程环境。
FastAPI 适合数据类、算法类、快速原型和 Python 生态技术栈。Python 在文本处理、向量计算和 AI 生态上有天然优势,启动快,代码量少,适合先把业务逻辑验证清楚。
Spring Boot 适合企业级后端,尤其是已有 Java 技术栈、统一微服务治理、监控体系和发布流程的团队。Java 在类型约束、编译期检查、依赖管理和大规模协作上有优势,适合把 AI 能力嵌入现有业务系统。
| 对比维度 | FastAPI 版本 | Spring Boot 版本 |
|---|---|---|
| 开发语言 | Python 3.10+ | Java 17+ |
| 启动速度 | 快,适合原型验证 | 相对慢,适合长期维护 |
| 类型约束 | Pydantic 运行时校验 | 编译期类型检查 |
| AI 生态 | 丰富,向量、解析类库多 | 依赖 spring-ai 等集成包 |
| 部署形态 | 多用于独立 AI 服务 | 多见于已有业务系统内嵌 |
| 适合场景 | 算法团队、快速迭代 | 企业治理、统一发布规范 |
学习时可以以 FastAPI 为主理解原理,再对照 Spring Boot 版本看同一套接口如何用 Java 落地。两套代码尽量保持接口路径和消息结构一致,方便切换。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 项目骨架与基础环境准备
2.1 统一的项目目录结构
先设计一个能力分层的目录结构。FastAPI 版本按模块划分路由和服务:
text复制ai-family/
├── app/
│ ├── main.py # FastAPI 入口
│ ├── core/
│ │ ├── config.py # 全局配置
│ │ ├── llm_client.py # 大模型调用封装
│ │ └── message.py # 统一消息结构
│ ├── modules/
│ │ ├── chat/ # 基础对话
│ │ ├── rag/ # 知识库检索
│ │ ├── tools/ # 工具注册与执行
│ │ ├── agent/ # Agent 循环
│ │ ├── workflow/ # 工作流引擎
│ │ └── mcp/ # MCP 客户端
│ ├── schemas/
│ │ ├── chat.py
│ │ └── common.py
│ └── routers/
│ ├── chat.py
│ ├── rag.py
│ ├── agent.py
│ └── workflow.py
├── data/
│ └── docs/ # 本地知识库原始文档
├── requirements.txt
└── .env.example
Spring Boot 版本沿用同一套模块划分,但包名与约定按 Java 规范调整:
text复制ai-family-java/
├── pom.xml
├── src/main/java/com/example/aifamily/
│ ├── AiFamilyApplication.java
│ ├── common/
│ │ ├── config/AppProperties.java
│ │ └── model/ChatMessage.java
│ ├── module/
│ │ ├── chat/ChatController.java
│ │ ├── rag/RagController.java
│ │ ├── tool/ToolController.java
│ │ ├── agent/AgentController.java
│ │ └── workflow/WorkflowController.java
│ └── service/
│ ├── LlmClient.java
│ ├── VectorStore.java
│ └── ToolExecutor.java
└── src/main/resources/
└── application.yml
目录结构的原则是:入口、核心客户端、业务模块、对外路由四层分开。后面替换依赖或新增模块时只需要在对应目录内操作。
2.2 FastAPI 版本依赖配置
FastAPI 版本最小依赖如下,核心诉求是:HTTP 框架、请求校验、环境配置、向量计算、可选的文件解析。
text复制fastapi>=0.110.0
uvicorn[standard]>=0.29.0
pydantic>=2.0.0
pydantic-settings>=2.0.0
httpx>=0.27.0
numpy>=1.26.0
python-dotenv>=1.0.0
如果后续需要解析 PDF、Word 等格式,再按需增加:
text复制pypdf>=4.0.0
python-docx>=1.1.0
注意不要一次性引入过重的向量数据库依赖。学习阶段先用内存向量库跑通链路,后续替换 Milvus、Elasticsearch 或 PGVector 时只影响 VectorStore 一个类。
创建虚拟环境并安装:
bash复制python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
启动服务:
bash复制uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
2.3 Spring Boot 版本依赖与基础配置
Spring Boot 版本使用 Java 17 和 Spring Boot 3.x。最稳妥的做法是先用原生 HTTP 客户端封装模型调用,而不是一开始引入某个版本差异很大的 AI Starter,避免不同版本 API 不一致导致大规模返工。
xml复制<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
基础配置写在 application.yml 中:
yaml复制server:
port: 8080
spring:
application:
name: ai-family-service
ai:
llm:
base-url: ${LLM_BASE_URL:http://localhost:11434/v1}
api-key: ${LLM_API_KEY:local-key}
model: ${LLM_MODEL:qwen2.5:7b}
timeout-seconds: ${LLM_TIMEOUT_SECONDS:60}
vector:
dimension: ${VECTOR_DIMENSION:768}
top-k: ${RAG_TOP_K:5}
这里把模型地址配置成了 OpenAI 兼容接口的常见格式。本地可以用 Ollama,生产环境可以替换为各类模型网关,关键是接口语义一致。
默认值不写死某种厂商,落地前必须确认实际模型服务支持哪种协议和版本。
3. 先实现最底层的 LLM 调用能力
3.1 为什么要先封装统一的模型调用接口
早期项目常常直接在业务代码里调用模型 SDK,例如在 Controller 里拼接 URL、设置 Header、解析响应。一旦模型服务从测试环境切到生产环境,或者更换模型供应商,就需要改动所有业务层。更好的做法是在系统入口先封装一个 LlmClient,屏蔽模型提供方的差异。
统一封装后,业务层只关心三件事:
- 传什么消息。
- 期望什么格式的输出。
- 请求失败后怎么处理。
模型接口、密钥、超时、重试细节全部集中在封装的内部实现里。
3.2 FastAPI 版本的 LlmClient 最小实现
以 OpenAI 兼容接口为例,用 httpx 发送请求。这条路径可以对接多数兼容服务,也便于调试。
python复制import httpx
from core.config import settings
class LlmClient:
def __init__(self):
self.base_url = settings.llm_base_url
self.api_key = settings.llm_api_key
self.model = settings.llm_model
self.timeout = settings.llm_timeout_seconds
async def chat(
self,
messages: list[dict],
temperature: float = 0.7,
max_tokens: int = 2048,
) -> dict:
url = f"{self.base_url}/chat/completions"
payload = {
"model": self.model,
"messages": messages,
"temperature": temperature,
"max_tokens": max_tokens,
}
headers = {"Authorization": f"Bearer {self.api_key}"}
async with httpx.AsyncClient(timeout=self.timeout) as client:
resp = await client.post(url, json=payload, headers=headers)
resp.raise_for_status()
data = resp.json()
return {
"content": data["choices"][0]["message"]["content"],
"usage": data.get("usage", {}),
}
这里的关键点是:messages 统一采用 OpenAI 消息格式,即 role 为 system、user、assistant、tool 的消息列表。后面 RAG 和 Agent 都要依赖这个格式,所以第一步就必须对齐。
3.3 Spring Boot 版本的 LlmClient 实现
Spring Boot 版本同样不直接依赖厂商 SDK,而是用 WebClient 发起 HTTP 请求,并使用记录类型承载响应。
java复制public record ChatMessage(String role, String content, List<ToolCall> toolCalls) {
public static ChatMessage user(String content) {
return new ChatMessage("user", content, null);
}
public static ChatMessage system(String content) {
return new ChatMessage("system", content, null);
}
}
java复制@Service
public class LlmClient {
private final WebClient webClient;
private final AppProperties props;
public LlmClient(WebClient.Builder builder, AppProperties props) {
this.props = props;
this.webClient = builder
.baseUrl(props.getLlm().getBaseUrl())
.defaultHeader("Authorization", "Bearer " + props.getLlm().getApiKey())
.build();
}
public ChatResponse chat(List<ChatMessage> messages) {
Map<String, Object> body = new HashMap<>();
body.put("model", props.getLlm().getModel());
body.put("messages", messages.stream()
.map(msg -> Map.of("role", msg.role(), "content", msg.content()))
.toList());
body.put("temperature", 0.7);
body.put("max_tokens", 2048);
return webClient.post()
.uri("/chat/completions")
.bodyValue(body)
.retrieve()
.bodyToMono(ChatResponse.class)
.timeout(Duration.ofSeconds(props.getLlm().getTimeoutSeconds()))
.block();
}
}
Java 版本需要注意 record 类和 JSON 字段映射。如果模型返回字段顺序不稳定,建议用 Jackson 的 @JsonIgnoreProperties(ignoreUnknown = true) 忽略未知字段,避免模型服务新增字段导致反序列化失败。
3.4 LLM 调用参数说明与常见配置场景
模型参数直接影响回答质量和成本,列出最常用的几个:
| 参数 | 含义 | 常见值 | 调大/调小的表现 | 推荐场景 |
|---|---|---|---|---|
| temperature | 采样随机性 | 0.2-0.8 | 调大更发散,调小更确定 | 问答偏低,创意偏高 |
| max_tokens | 最大生成 token 数 | 1024-4096 | 过大浪费成本,过小截断 | 长文生成调大,分类任务调小 |
| top_p | 核采样概率 | 0.8-0.95 | 综合影响输出多样性 | 一般无需同时调两大参数 |
| timeout | 请求超时时间 | 30-120 秒 | 过短误杀长任务 | 复杂 Agent 请求调大 |
| stream | 是否流式返回 | false/true | 流式提升体验但排查复杂 | 先关流式,跑通再开 |
这里要特别提醒一个容易踩的坑:temperature 和 top_p 不要同时大幅度调整。多数模型建议只调其中一个,两者同时大幅改变会导致输出分布不稳定,问题更难排查。
4. RAG 接入:从文档切分到检索增强的回答
4.1 RAG 最小链路是五步,不是三步
很多教程把 RAG 简化成“向量化、检索、拼接”,忽略了文档切分这一步。实际上切分质量直接决定检索效果。如果把一段包含多个主题的文本整体切成一个向量,用户提问只涉及其中一部分时,检索到的内容噪音很大。
最小 RAG 链路应包含五步:
- 加载文档。
- 按语义边界切分成小块。
- 对每个小块做向量化(Embedding)。
- 用户提问后,对问题做向量化并检索相似片段。
- 把检索片段作为上下文与问题一起组装成大模型请求。
4.2 FastAPI 版本的向量存储与检索
学习阶段用内存列表作为向量库,Python 里用 NumPy 计算余弦相似度。
python复制import numpy as np
class MemoryVectorStore:
def __init__(self, dimension: int):
self.dimension = dimension
self.vectors = []
self.texts = []
def add(self, text: str, vector: list[float]):
self.texts.append(text)
self.vectors.append(np.array(vector, dtype=np.float32))
def search(self, query_vector: list[float], top_k: int = 5):
q = np.array(query_vector, dtype=np.float32)
scores = [
(float(np.dot(q, v) / (np.linalg.norm(q) * np.linalg.norm(v) + 1e-9)), i)
for i, v in enumerate(self.vectors)
]
scores.sort(reverse=True)
return [(self.texts[i], score) for score, i in scores[:top_k]]
向量化接口通常由模型服务或专用 Embedding 服务提供。以 OpenAI 兼容格式为例:
python复制async def embed_text(text: str) -> list[float]:
url = f"{settings.llm_base_url}/embeddings"
payload = {"model": settings.embedding_model, "input": text}
async with httpx.AsyncClient(timeout=30) as client:
resp = await client.post(url, json=payload, headers=headers)
resp.raise_for_status()
data = resp.json()
return data["data"][0]["embedding"]
文档切分先做最基础的按窗口切分,固定长度加重叠:
python复制def split_text(text: str, chunk_size: int = 512, overlap: int = 64):
chunks = []
start = 0
while start < len(text):
end = start + chunk_size
chunks.append(text[start:end])
start = end - overlap
return chunks
重叠的目的是避免检索时关键词刚好落在切分边界上导致片段丢失语义。
4.3 RAG 接口组装 Prompt
检索到的文本不能直接拼到用户问题后面,最好是放在 system 消息里明确标注知识来源,并告诉模型只能基于给定上下文回答。
python复制context = "\n\n".join([text for text, score in top_chunks])
messages = [
{
"role": "system",
"content": (
"你是一个企业内部知识助手。请优先根据提供的资料回答,"
"如果资料里没有对应信息,请明确说不知道。"
f"\n\n参考资料:\n{context}"
),
},
{"role": "user", "content": question},
]
这里的关键是给模型“知识边界”。不写这段提示词,模型就会混入自己的训练知识,回答看似流畅,但可能偏离企业内部事实。
4.4 RAG 检索效果怎么看:三个核心检查点
RAG 结果不好不一定是模型问题,大多是检索环节出了问题。按以下顺序排查:
- 切分粒度。
chunk_size过大导致一个片段包含太多主题,检索命中但不精准;过小则上下文碎片化,丢失逻辑关系。常见文本可以先用 512 字加 64 字重叠,再按效果调整。 - 向量模型是否与文本语言匹配。中文场景使用中文表现更好的向量模型。
top_k是否合适。取回片段太少可能漏掉关键信息,太多则超出上下文窗口且引入噪声。
| 表现 | 可能原因 | 处理方式 |
|---|---|---|
| 回答像没读资料 | 检索片段未传入 Prompt | 打印实际发给模型的 messages |
| 答非所问但资料里有 | chunk_size 过大或 top_k 太小 | 减小切分窗口,增大 top_k |
| 检索到大量无关内容 | 向量模型与语言不匹配 | 更换更适合中文的 Embedding 模型 |
| 回答引用错误信息 | 未在 Prompt 里限制知识边界 | 增加“仅基于资料回答”的约束 |
5. 让模型会调用工具:函数调用是 Agent 的地基
5.1 工具调用的本质:模型输出的是意图,不是执行结果
工具调用最容易被误解。有人以为“模型能调用工具”意味着模型自己运行代码,实际完全不是。模型只负责输出一段结构化 JSON,表达“我想调用 get_weather,参数是 city=Beijing”。是否执行、如何执行由程序决定。
调用流程是:
- 程序把工具列表发给模型。
- 模型判断是否需要调用工具。
- 如果需要,返回工具名称和参数。
- 程序执行对应的真实函数。
- 把执行结果以 tool 角色消息回传给模型。
- 模型基于结果生成最终回答或继续调用下一个工具。
5.2 工具定义与工具注册表
无论 FastAPI 还是 Spring Boot,工具都需要有结构化的描述。以一个查询服务器状态的工具为例,它的 JSON Schema 描述如下:
json复制{
"type": "function",
"function": {
"name": "query_server_status",
"description": "查询指定服务器的运行状态",
"parameters": {
"type": "object",
"properties": {
"host": {
"type": "string",
"description": "服务器主机名或 IP"
}
},
"required": ["host"]
}
}
}
FastAPI 版本用字典注册工具:
python复制def query_server_status(host: str) -> str:
# 模拟真实查询,实际项目替换为监控系统 API 或数据库查询
return f"服务器 {host} 状态正常,CPU 负载 12%,磁盘剩余 180GB"
TOOL_REGISTRY = {
"query_server_status": {
"func": query_server_status,
"schema": tool_schema,
}
}
Spring Boot 版本推荐用一个轻量的函数注册表:
java复制@Component
public class ToolRegistry {
private final Map<String, Map<String, Object>> schemas = new HashMap<>();
private final Map<String, java.util.function.Function<String, String>> executors = new HashMap<>();
@PostConstruct
public void register() {
register("query_server_status",
Map.of("type", "function",
"function", Map.of(
"name", "query_server_status",
"description", "查询指定服务器的运行状态",
"parameters", Map.of(
"type", "object",
"properties", Map.of("host", Map.of("type", "string", "description", "服务器主机名或 IP")),
"required", List.of("host")))),
args -> "服务器状态正常,CPU 负载 12%");
}
public void register(String name, Map<String, Object> schema, java.util.function.Function<String, String> executor) {
schemas.put(name, schema);
executors.put(name, executor);
}
}
5.3 工具调用循环:多轮追加消息
模型的工具调用接口通常会返回类似 TOOL_CALL 的结果,程序需要取出工具名称和参数并执行。参考实现如下:
python复制async def run_tool_loop(user_query: str, max_rounds: int = 5):
messages = [{"role": "user", "content": user_query}]
tools = [registry["query_server_status"]["schema"]]
for round_index in range(max_rounds):
resp = await llm.chat_with_tools(messages, tools)
message = resp["message"]
if not message.get("tool_calls"):
return message.get("content")
messages.append({
"role": "assistant",
"content": message.get("content") or "",
"tool_calls": message["tool_calls"],
})
for tool_call in message["tool_calls"]:
name = tool_call["function"]["name"]
arguments = json.loads(tool_call["function"]["arguments"])
executor = TOOL_REGISTRY[name]["func"]
result = executor(**arguments)
messages.append({
"role": "tool",
"tool_call_id": tool_call["id"],
"content": result,
})
return "达到最大工具调用轮数,已停止"
核心在 messages.append 的过程中。模型需要看到 assistant 的调用意图和 tool 的执行结果才能继续,如果没有这两个消息,模型会像失忆一样重复要求调用同一个工具。
5.4 工具参数解析失败是高频问题
模型返回的工具参数是 JSON 字符串,但模型偶尔会输出格式不严格的 JSON,例如多一个逗号、使用单引号、或者带注释。不要直接抛异常,要加一层宽容解析:
python复制def safe_json_loads(text: str):
try:
return json.loads(text)
except json.JSONDecodeError:
# 尝试去掉 markdown 代码块标记
cleaned = text.strip()
if cleaned.startswith("```"):
cleaned = cleaned.strip("`")
if cleaned.startswith("json"):
cleaned = cleaned[4:]
return json.loads(cleaned)
如果多次解析失败,把解析失败信息追加到 messages 里回传给模型,让模型重新生成参数,而不是直接中断流程。
6. Agent、MCP 与工作流的编排思路
6.1 Agent 循环与工作流的区别
Agent 是动态循环,工作流是固定步骤。以排查服务器故障为例:
Agent 方式:模型自己判断先查哪个工具、查完看结果、再决定是否调用另一个工具,过程不可完全预知。
工作流方式:事先定义“查询状态 -> 拉取错误日志 -> 生成报告”三步,每一步严格顺序执行,中间不依赖模型决策。
实际项目里两者会结合使用。固定步骤用工作流保证稳定性,开放决策用 Agent 提供灵活性。先确定哪些步骤固定,再让模型在剩余决策点上发挥作用,是更务实的做法。
6.2 MCP 如何统一工具接入
MCP 将工具接入标准化成三层关系:Host(大模型应用)、Client(应用内的 MCP 客户端)、Server(暴露工具和数据资源的服务端)。应用通过 MCP 客户端与多个 MCP Server 建立连接,每个 Server 只负责暴露自己的工具,不需要业务侧为每个服务写一套调用代码。
一次 MCP 工具调用在逻辑上包括:
json复制{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "query_server_status",
"arguments": {
"host": "web-01"
}
}
}
业务系统不直接调用服务端 API,而是向 MCP Client 发标准请求。这样新工具上线时,只需要新增一个 MCP Server 并注册,不需要改动主应用的业务代码。
建议的 MCP 接入边界是:对内部已有系统,如果一次只需要接入两三个工具,直接用工具注册表更简单;如果工具数量持续增长、多个团队都要复用,再引入 MCP 统一标准。
6.3 串联所有能力的最小工作流描述
用一个 JSON 定义工作流,让入口根据步骤依次执行:
json复制{
"workflow": "server_fault_report",
"steps": [
{"type": "intent", "name": "intent_judge"},
{"type": "rag", "name": "retrieve_docs", "query_key": "original_query"},
{"type": "tool", "name": "query_server_status", "params": {"host": "${host}"}},
{"type": "llm", "name": "generate_answer", "context_keys": ["retrieve_docs", "query_server_status"]}
]
}
一个简单的工作流引擎循环执行 steps,每个步骤读取 context 中的输入,写回 context 中的输出。最后一步的模型生成使用前序步骤的结果汇总。这样编排的好处是流程调整只改 JSON,不改代码。
6.4 FastAPI 与 Spring Boot 的能力对照
| 能力 | FastAPI 实现要点 | Spring Boot 实现要点 |
|---|---|---|
| LLM 调用 | httpx 封装 OpenAI 兼容接口 | WebClient 封装 HTTP 调用 |
| 向量检索 | NumPy 内存索引 | Java List + 余弦相似度手写 |
| 工具注册 | 字典保存 schema 与函数 | Spring Bean + Map 注册 |
| Agent 循环 | Python 循环 + 消息列表 | Java 循环 + List |
| 工作流 | JSON 驱动步骤执行 | Jackson 解析 JSON + Map 上下文 |
| MCP 接入 | 实现 MCP Client 请求逻辑 | 同样通过 HTTP 与 MCP Server 通信 |
两个版本本质上做同一件事:用统一消息格式串联每一步,用独立组件封装每一种能力,用上下文对象传递中间结果。区别只是语言和生态差异。
7. 从现象倒推根因:典型链路排查
7.1 高频异常与处理对照
开发 AI 服务时遇到的大部分问题可以通过“打印消息、确认配置、检查超时”三步缩小范围。
| 问题现象 | 常见原因 | 检查方式 | 处理建议 |
|---|---|---|---|
| 接口超时 | 模型推理时间超过客户端超时 | 检查 LlmClient 超时配置;查看模型服务日志 | 调大 timeout;复杂任务改异步回调 |
| 返回结果被截断 | max_tokens 设置过小 | 打印 usage 中的 finish_reason | 增大 max_tokens;如果 finish_reason 为 length,说明截断 |
| 模型不知道工具调用 | 工具参数未传入或格式不匹配 | 打印发给模型的 tools JSON | 对照 OpenAI 兼容规范修正 schema |
| 工具参数解析失败 | 模型输出了不合法 JSON | 打印 tool_calls 原始内容 | 增加宽容解析,失败后回传错误让模型重试 |
| RAG 回答不带资料 | Prompt 中未拼接检索上下文 | 打印实际 messages | 把检索结果拼进 system 内容 |
| 中文回答生硬 | 系统提示词缺少风格约束 | 检查 system prompt | 在提示词中明确语气、格式和边界 |
| 同一次请求耗时波动大 | 输入长度和候选 token 数不同 | 记录每次 usage 数据 | 固定提示词模板,限制最大生成长度 |
7.2 链路日志:从入口到模型输出都加请求 ID
AI 应用排查最怕没有上下文关联。一个请求会经历 LLM 调用、向量检索、工具执行等多步,每一步都可能产生日志。建议在入口生成 request_id,贯穿所有模块。
python复制import uuid
request_id = uuid.uuid4().hex
logger.info("request start", extra={"request_id": request_id, "module": "api"})
logger.info("rag top_k result", extra={"request_id": request_id, "top1_score": 0.91})
logger.info("tool_call result", extra={"request_id": request_id, "tool": "query_server_status"})
Spring Boot 版本可以使用 MDC 或自定义日志过滤器。核心是让一次请求的所有日志可聚合检索,否则排查时只能人肉拼凑时间线。
7.3 学习环境与生产环境的检查清单
学习环境跑通的最短检查清单:
- LLM 服务可用,
/chat/completions返回 200。 - Embedding 服务可用,向量维度与 VectorStore 配置一致。
- 文档已切分并写入向量库,
search能返回结果。 - 工具 schema 能被模型识别,
tool_calls有空值说明识别失败。 - 接口间消息格式统一,
role字段没有拼错。
生产环境额外检查清单:
- 模型地址和密钥通过环境变量注入,不写进代码库。
- 对模型调用配置超时、重试、熔断和告警。
- 敏感系统工具加权限校验,例如“查询服务器状态”不应允许所有调用者访问。
- 记录每次请求的 token 消耗,建立成本基线。
- 对 Agent 的最大循环轮数做硬限制,防止死循环和成本飙升。
8. 工程化落地建议与后续扩展方向
8.1 生产环境必须处理的六件事
第一件事是配置外置。模型地址、密钥、超时、top_k 这些参数不能硬编码,统一放环境变量或配置中心,至少保证测试和生产可以切换。
第二件事是日志与可观测。给每次请求加 request_id,按模块输出耗时和 token 数。出现回答质量问题时,能回放该请求到底发送了什么消息。
第三件事是重试与限流。模型服务超时或返回 5xx 时要有重试策略,但重试次数要限制,并且重试时不能无脑重复提交相同请求,要考虑幂等和成本。
第四件事是 Prompt 安全。不要直接把用户输入拼进系统提示词,否则可能被注入。对涉及内部系统操作的工具,在程序侧校验参数白名单和调用权限,不能只依赖模型判断。
第五件事是上下文管理。RAG 检索结果和 Agent 中间轮次都会增长上下文,超出窗口后会报错或丢失早期信息。需要设计摘要压缩机制或定期清理工具中间消息。
第六件事是可回滚。新的模型版本、新的切分策略、新的工具逻辑上线前保留上一版配置,发现问题能快速切换。
8.2 学习顺序建议:不要一上来就搭 Agent
这套全家桶的正确学习顺序应该是:
- 先封装 LLM 调用,用
/api/chat跑通最基本的对话。 - 再做 RAG,把文档载入和检索链路跑起来。
- 再做工具调用,选一个真实查询场景,例如查服务器状态或查订单。
- 再在工具调用之外加 Agent 循环,让模型能连续调用多个工具。
- 再引入工作流,把几个固定步骤固化。
- 最后再接 MCP,用标准协议接入外部工具服务。
每完成一步就验证一步,不要一次性写完所有代码再调试。工具调用和 Agent 是排查成本最高的两个模块,分步学能明显降低定位难度。
8.3 可以继续扩展的方向
完成这套骨架后,可以朝几个方向继续深入:
- 流式输出:把模型生成过程以 SSE 推送到前端,提升交互体验。
- 多模态:在消息结构中支持图片输入,例如截图故障现场让模型分析。
- 可评估的 RAG:建设评测集,量化检索命中率和最终回答质量,而不是靠感觉调参。
- 结构化输出:用 JSON Schema 约束模型输出,方便下游系统消费。
- 工作流可视化:将 JSON 流程定义接入管理后台,让运营人员配置流程而不是修改代码。
这一套双版本骨架的核心价值在于把复杂 AI 能力拆成边界清晰的模块。先跑通,再替换,再治理,比一开始追求某个“全家桶框架”更实际。每个模块的替换点都已经留好:模型服务可以换、向量库可以换、工具实现可以换、工作流定义可以换,业务代码调整范围会控制在很小区间内。
