主题
手撕 vs Spring AI:从 Servlet 到 SpringMVC 的顿悟
同样的协议层,同样的抽象意图。手写一遍才知道框架到底省了什么。
做 LLM 应用,到底要不要上框架?
刚开始接触 LLM 开发的人都会遇到这个选择:是直接用 HTTP 客户端调 API,还是套一层 Spring AI、LangChain 之类的框架?
直接调 HTTP 的好处是透明——发什么、收什么,你一眼都能看到。坏处是样板代码多,每个项目都要从头写一遍 HTTP 请求、JSON 序列化、错误处理。
上框架的好处是快——几行代码就能跑起来多轮对话、Function Calling、RAG。坏处是黑盒,出了问题你不知道是框架的锅还是模型的锅。
我花了两周时间,先用 OkHttp + Jackson 纯手写了一遍 LLM 全链路,再用 Spring AI 重新做了一遍。这篇是两边的对比和结论。
类比一下:Spring AI 之于手撕 LLM,就像 Spring MVC 之于 Servlet。
- 手撕 Servlet:你写
doGet/doPost,手写HttpServletRequest解析,手动拼 JSON 响应 - Spring MVC:
@RequestMapping+@ResponseBody—— 同一件事,少了 80% 的样板代码
理解 Servlet,你才知道 Spring MVC 的"自动"到底替你做了什么。理解手撕 LLM,你才知道 Spring AI 的"省"省在哪里。
先铺垫一下:LLM 应用的骨架
在进入对比之前,先把 LLM 应用的基本结构说清楚。不管用不用框架,一个完整的 LLM 应用大概就这几层:
- HTTP 调用层:往模型 API 发 POST 请求,带 JSON body,拿响应回来
- 对话记忆层:维护历史消息列表,每次都全量发给模型
- 工具调用层:告诉模型有哪些函数可用,检测模型的 tool_calls,执行函数,把结果回喂
- Agent 编排层:用循环驱动模型反复推理 + 调用工具,直到给出最终答案
- RAG 检索层:从知识库中检索相关片段,塞进 Prompt 里
下面逐一对比手撕和 Spring AI 的实现。
一、HTTP 调用层:从 OkHttp 到 ChatClient
问题是什么
调用大模型,本质上就是发一个 HTTP POST 请求。请求体是 JSON,里面带 model 名称和 messages 数组。响应也是 JSON,里面有模型返回的内容。
就这么简单。但每次都手写 HTTP 客户端、拼 JSON、处理异常,确实很烦。
手撕实现
java
RequestBody body = new Gson().toJson(Map.of(
"model", "doubao-seed-1-6",
"messages", List.of(Map.of("role", "user", "content", "你好"))
));
Request request = new Request.Builder()
.url("https://ark.cn-beijing.volces.com/api/v3/chat/completions")
.post(RequestBody.create(body, JSON))
.addHeader("Authorization", "Bearer " + key)
.build();
Response response = client.newCall(request).execute();这段在做什么:用 Gson 把 Map 序列化成 JSON,构造一个 Request 对象,指定 URL、POST 方法、请求体、认证头,然后执行请求。
关键几行:
- 第 1-4 行:构造请求体。
model指定用哪个模型,messages是对话消息列表,最简单的情况就是一条 user 消息 - 第 5-9 行:构造 Request。URL 是火山方舟的 chat completions 接口,POST 方式,Content-Type 是 JSON,Header 里带 Bearer token
- 第 10 行:同步执行请求,拿到 Response
换别的写法会怎样:你也可以用 HttpClient(Java 11+ 自带)或者 RestTemplate,本质一样——都是发 HTTP 请求。区别只在 API 风格和异常处理方式。
Spring AI 实现
java
String answer = chatClient.prompt("你好").call().content();一行搞定。chatClient.prompt() 设置用户输入,.call() 同步调用,.content() 拿到返回的文本内容。
对比
Spring AI 帮你省掉了:HTTP 客户端配置、请求体 JSON 拼装、Header 注入、响应解析、异常处理。
但你也失去了透明度。第一次用 Spring AI 的人,如果没手写过 HTTP 调用,可能真的以为 LLM 调用是什么"魔法返回"——不知道背后就是个普通的 POST 请求。
二、对话记忆:从 List<Message> 到 ChatMemory
问题是什么
大模型本身是无状态的。你每次发请求,它都不记得上一次聊了什么。要实现多轮对话,你得自己把历史消息攒起来,每次请求都把完整的消息列表发过去。
这是一个反直觉的点:所谓的"多轮对话",不是模型记住了,而是你每次都把聊天记录全量发给它。
手撕实现
java
messages.add(new Message("user", userInput));
// 拼 body 发出去
messages.add(new Message("assistant", responseContent));
// 下轮循环,messages 越来越长这段在做什么:用一个 List<Message> 维护对话历史。用户说一句话,加进列表;模型回复了,也加进列表。下一轮对话的时候,整个列表一起发给模型。
关键几行:
- 第 1 行:把用户输入追加到消息列表,role 是
user - 第 3 行:把模型回复也追加进去,role 是
assistant - 就这两行是核心——多轮对话的全部秘密就是一个不断增长的数组
换别的写法会怎样:你可以用队列、用环形缓冲、用数据库存——但基本思路都是"攒消息、全量发"。区别只在存储介质和截断策略。
Spring AI 实现
配置 ChatMemory 接口,有 InMemoryChatMemory、JDBC、Redis 等多种实现。滑动窗口阈值、截断策略框架自动处理。
用法上,你只要告诉 ChatClient 用哪个 ChatMemory,它就自动管理消息列表了。
对比
Spring AI 帮你省掉了:手写 List<Message> 管理、自己实现窗口截断、存储层切换(从内存到数据库只需要换实现类)。
但框架没有改变"多轮对话 = 累计消息列表"这个事实。它只是把这个事实封装成接口,让你不用每次都手写 messages.add()。
三、Function Calling:从 30 行 JSON 到 @Tool 注解
问题是什么
让大模型调用外部工具,是 Agent 能力的基础。模型不直接执行代码,而是返回一个"我要调用这个函数、参数是这些"的指令,你的程序去执行,执行结果再回喂给模型。
这个交互协议需要两边对齐:你得告诉模型有哪些函数可用(函数名、描述、参数 schema),模型得按约定的格式返回 tool_calls,你再按约定的格式把结果塞回去。
手撕实现
首先要手写工具的 JSON Schema 描述:
java
// 手写 30 行 JSON Schema
String toolsJson = """
{
"type": "function",
"function": {
"name": "get_weather",
"description": "获取城市天气",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string"}
},
"required": ["city"]
}
}
}
""";然后还要自己写:
- 检测响应里的
tool_calls字段 - 用
switch(name)分发到对应的 Java 方法 - 把执行结果拼成
role=tool的消息回喂给模型
这段在做什么:用字符串拼出符合 OpenAI Function Calling 协议的 JSON Schema,告诉模型"我有一个叫 get_weather 的工具,接收一个 city 参数,你可以调用它"。
关键几行:
name:函数名,模型返回 tool_calls 时会用这个名字标识description:函数描述,模型根据描述判断什么时候该调用这个工具parameters:参数的 JSON Schema 定义,模型根据这个生成合法的参数值required:必填参数列表
换别的写法会怎样:你可以用 Java 对象 + Jackson 序列化来生成 Schema,比手写字符串更不容易写错。但本质一样——最终发出去的都是同一份 JSON。
Spring AI 实现
java
@Tool(description = "获取城市天气")
public String getWeather(String city) {
return "%s 晴 25°C".formatted(city);
}然后一行调用:
java
chatClient.prompt().tools(weatherService).call().content();对比
Spring AI 帮你省掉了:JSON Schema 生成(通过反射)、tool_calls 检测(框架自动做)、函数分发(反射调用)、role=tool 消息回喂(框架自动做)。
这是手撕和框架差距最大的一块——手写要 30 行 schema + 20 行分发逻辑,Spring AI 一行注解搞定。
但手撕过你就知道,Spring AI 的"自动"背后就是那 30 行 JSON + 20 行 switch。它没在变魔术,只是把协议层的样板代码抽象掉了。
四、Agent 编排:框架只做了一半
问题是什么
Agent 的核心是一个循环:让模型思考 → 判断是否需要调用工具 → 调用工具 → 把结果喂回去 → 再思考 → 直到模型给出最终答案。
这个循环叫 ReAct(Reasoning + Acting)。问题是,这个循环的控制权应该在框架手里,还是在应用手里?
手撕实现
java
while (!done && round < MAX_ROUNDS) {
Response resp = llm.chat(messages);
if (isFinalAnswer(resp)) break;
if (isToolCall(resp)) {
String result = executeTool(resp.function, resp.args);
messages.add(toolMessage(result));
}
if (++round > MAX_ROUNDS) {
messages.add(forceAnswerMessage());
resp = llm.chat(messages);
done = true;
}
}这段在做什么:一个 while 循环,每轮调一次模型。如果模型返回了最终答案就退出;如果返回了工具调用就执行工具并把结果加回消息列表;如果轮次超限就强制模型给出答案。
关键几行:
- 第 1 行:循环条件,
done标记是否结束,MAX_ROUNDS防止死循环 - 第 2-3 行:调模型,判断是不是最终答案,是就 break
- 第 4-7 行:判断是不是工具调用,是就执行工具,把结果作为 tool message 加回去
- 第 8-12 行:轮次超限处理,加一条强制回答的消息,再调一次模型
换别的写法会怎样:你可以加更多分支——比如处理并行工具调用、处理模型拒绝回答、处理工具执行失败重试。但骨架还是这个 while 循环。
Spring AI 实现
Spring AI 里有两种做法:
- Framework 版:
chatClient.prompt().tools(tools).call().content()—— 一行搞定,但中间步骤完全黑盒 - 手写 ReAct 版:依然是 while 循环,只是把 HTTP 调用换成了
ChatClient.call()
对比
Spring AI 帮你省了协议层(HTTP、JSON、schema、路由),但 Agent 编排逻辑本身没省——ReAct 循环的控制权依然在你手里。
这也引出一个有意思的问题:Spring AI 为什么不提供一个完整的 Agent 框架?
因为 Spring 团队认为 Agent 是应用层,不是框架层。类比一下:Spring MVC 不提供"CRUD 应用模板"——它给你 Controller + Service + DAO,业务流程你自己写。Agent 编排也是一样,框架给你工具,循环你自己写。
五、RAG 检索:从 Jaccard 到向量数据库
问题是什么
RAG(检索增强生成)要解决的问题是:怎么让大模型使用私有数据?
直接把文档全塞进 Prompt 不现实——上下文窗口有限、成本高、效果也不一定好。所以做法是:先从文档库里检索出和问题相关的片段,只把这些片段塞进 Prompt。
检索这一步怎么做,决定了 RAG 的效果。
手撕实现:Jaccard 相似度
最简单的做法是用词面重合度来判断相关性。Jaccard 相似度 = 交集大小 / 并集大小。
java
Set<String> intersection = new HashSet<>(queryWords);
intersection.retainAll(docWords);
double jaccard = (double) intersection.size() / union.size();这段在做什么:把查询和文档都分词,算两个词集合的交集除以并集,得到一个 0 到 1 之间的相似度分数。
关键几行:
- 第 1 行:用查询词构造一个 HashSet
- 第 2 行:
retainAll保留同时出现在 docWords 里的词,也就是求交集 - 第 3 行:交集大小除以并集大小,得到 Jaccard 相似度
换别的写法会怎样:你也可以用 TF-IDF、BM25 等更复杂的词面检索算法,效果会比纯 Jaccard 好。但本质还是"看字面重叠程度"。
Spring AI 实现:向量数据库
Spring AI 通过 VectorStore 接口接入各种向量数据库。以 Qdrant 为例:
yaml
spring:
ai:
vectorstore:
qdrant:
host: localhost
port: 6334
collection-name: resumejava
List<Document> hits = vectorStore.similaritySearch(
SearchRequest.builder().query(query).topK(3).build()
);配置好之后,一行代码就能做语义检索。换 Milvus、换 Pinecone、换 Chroma,都是同一套 VectorStore 接口。
Jaccard vs Embedding 对比测试
我用简历 RAG 做了三组对比:
| 问题 | Jaccard 词面检索 | Embedding 语义检索 | 结论 |
|---|---|---|---|
| "祥哥做过哪些项目" | ✅ 命中 "支付网关" | ✅ 命中 + 诚实说"其他未提及" | 都行,Embedding 更精确 |
| "祥哥会用 React 吗" | ✅ 命中"不擅长前端" | ✅ 命中,但倾向于正面回答 | 都行,但 Embedding 有时忽略否定词 |
| "祥哥的教育背景" | ❌ 翻车(字面零重合) | ✅ 命中"毕业211硕士" | Embedding 完胜 |
"教育背景"这个案例最说明问题。Jaccard 算交集,"教育"、"背景"跟"毕业"、"211"、"硕士"字面零重合,得分直接是 0。Embedding 在高维空间里理解到"教育背景"的语义接近"毕业于某211硕士"。
这就是语义检索的价值——不是向量库在炫技,是检索这一步不再只看字面,而是看意思。
小结
Spring AI 帮你省掉了:Qdrant gRPC 客户端配置、embedding 调用串联、向量相似度计算。
但 RAG 的骨架没变——还是"检索什么 → 怎么拼 Prompt → 怎么约束模型不幻觉"。向量库只是让"检索"这一步更聪明,RAG 的整体框架没有变。
框架的边界:省了什么,没省什么
| 环节 | 手撕版 | Spring AI 版 | 省了? |
|---|---|---|---|
| HTTP 请求 | OkHttp 手写 | ChatClient.call() | ✅ 省了 |
| JSON 序列化 | Jackson 手拼 | 对象自动序列化 | ✅ 省了 |
| Tool schema | 手写 30 行 JSON | @Tool 注解 | ✅ 省了 |
| Tool 路由 | switch case | 反射调用 | ✅ 省了 |
| Tool 回喂 | 手拼 role=tool | 框架自动 | ✅ 省了 |
| 向量检索 | Jaccard 手算 | VectorStore.similaritySearch() | ✅ 省了 |
| ReAct 循环 | while + 解析 | while + 解析(没变) | ❌ 没省 |
| MAX_ROUNDS | 自己定 | 框架有默认 | ⚡ 部分省 |
| 中间步骤 | 全在代码里 | Framework 黑盒 | ❌ 没省 |
| Prompt 设计 | 自己写 | 自己写 | ❌ 没省 |
省的是协议层——HTTP、JSON、schema、路由,这些每做一个项目都要重复写的东西。
没省的是应用层——Agent 编排、Prompt 设计、检索策略,这些决定效果好坏的东西。
框架是省你时间的,不是省你理解的。
熟悉的配方:Spring 的老套路
对 Java 后端来说,Spring AI 最熟悉的地方在于它的架构模式:
- ChatClient = JdbcTemplate 的 LLM 版
- @Tool = @RequestMapping 的函数版
- Advisor = HandlerInterceptor
- ChatMemory = 缓存接口(多种实现随意切换)
- VectorStore = JPA Repository 的向量版
没有一个是新东西。都是 Java 后端十多年前就在用的模式——模板方法、注解驱动、拦截器、接口抽象、存储层切换。
变的只是上游:从一个确定性的数据库 API,变成了一个概率性的模型 API。它不像数据库那样保证"输入 A 出 B",它会"输入 A 大概率出 B,偶尔出 C"。
所以框架多做的一件事,是在不确定性上加护栏:ChatMemory 防遗忘、Tool Schema 防幻觉、ReAct 循环防跑偏、RAG 防编造。
接下来要碰的边界
- 多 Agent 协作:当一个 Agent 搞不定的时候,怎么拆任务、怎么协调
- 生产级 RAG:Rerank、HyDE、分块策略优化
- 可观测性:怎么让 Framework 版也能吐出中间步骤,而不是只给一个最终答案
最后说一句
先手撕,再上框架。手撕一遍让你知道框架的底层是什么,用框架让你知道框架替你省了什么。
理解不能被框架封装。
一个 Java 老兵,写于 2026-07-19