手撕 vs Spring AI:从 Servlet 到 SpringMVC 的顿悟
同样的协议层,同样的抽象意图,同样的"手写一遍才知道框架省了什么"。
Week 1 用 OkHttp + Jackson 手撕 LLM 全链路,Week 2 用 Spring AI 重新做了一遍。这篇是对比复盘。
开篇:为什么先手撕再上框架
在 Week 1 结束的时候,我写过一篇《Java 老兵一周手撕 LLM》,核心结论是:
框架不是魔法,是默认参数调好了的手写版。
Week 2 上 Spring AI,第一反应不是"真香",而是"这段代码我认识"——因为它底层就是 Week 1 那 5 个 Main 类拼起来的。
Spring AI 之于手撕,就像 Spring MVC 之于 Servlet:
- 手撕 Servlet:你写
doGet/doPost,手写HttpServletRequest解析,手动拼 JSON 响应 - Spring MVC:
@RequestMapping+@ResponseBody— 同一件事,少了 80% 的样板代码
你理解 Servlet 才知道 Spring MVC 的"自动"是替你做了什么。你理解手撕 LLM 才知道 Spring AI 的"省"省在哪里。
一、ChatClient → HTTP POST + JSON
手撕版(Week 1):
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();Spring AI 版(Week 2):
String answer = chatClient.prompt("你好").call().content();Spring AI 帮你省了:HTTP 客户端配置、请求体 JSON 拼装、Header 注入、响应解析、异常处理。
但你失去的:你不再看到 request body 长啥样了。第一次用 Spring AI 的人,如果没手撕过 HTTP 层,他可能以为 LLM 调用是"魔法返回"——不知道背后就是个 POST 请求。
二、ChatMemory → List<Message> 的滑动窗口
手撕版:手动维护一个 List<Message>,每次对话循环追加 user/assistant message,全量发出去。
messages.add(new Message("user", userInput));
// 拼 body 发出去
messages.add(new Message("assistant", responseContent));
// 下轮循环,messages 越来越长Spring AI 版:配置 ChatMemory 接口,InMemoryChatMemory / JDBC / Redis 实现。滑动窗口阈值、截断策略自动处理。
Spring AI 帮你省了:手写 List<Message> 管理、自己实现窗口截断、存储层切换(从内存到 DB 只需要换实现类)。
关键的认知:框架没有改变"多轮对话 = 累计消息列表"这个事实。它只是把这个事实封装成接口,让你不用每次都手写 messages.add()。
三、@Tool 注解 → 30 行 JSON Schema 变一行注解
手撕版(Week 1):
// 手写 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=toolmessage 回喂
Spring AI 版:
@Tool(description = "获取城市天气")
public String getWeather(String city) {
return "%s 晴 25°C".formatted(city);
}然后一行调完:
chatClient.prompt().tools(weatherService).call().content();Spring AI 帮你省了:JSON Schema 生成(反射)、tool_calls 检测(框架自动)、函数分发(反射调用)、role=tool 回喂(框架自动)。
这是 Week 1 到 Week 2 最直观的对比。手写 30 行 schema + 20 行分发逻辑,Spring AI 一行注解搞定。
但关键认知:手撕过才知道,Spring AI 的"自动"背后就是那 30 行 JSON + 20 行 switch。你知道它没在变魔术,只是把协议层样板代码抽象掉了。
四、Agent 编排 → 框架只做了一半
手撕版(Week 1):完整的 while 循环:
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;
}
}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,业务流程你自己写。
五、RAG + VectorStore → 一键替换检索策略
手撕版(Week 1):Jaccard 相似度,自己算交集/并集:
Set<String> intersection = new HashSet<>(queryWords);
intersection.retainAll(docWords);
double jaccard = (double) intersection.size() / union.size();Spring AI 版:声明式接入 Qdrant:
spring:
ai:
vectorstore:
qdrant:
host: localhost
port: 6334
collection-name: resumeList<Document> hits = vectorStore.similaritySearch(
SearchRequest.builder().query(query).topK(3).build()
);Spring AI 帮你省了:Qdrant gRPC 客户端配置、embedding 调用串联、向量相似度计算。换 Milvus 换 Pinecone 换 Chroma,都是同一套 VectorStore 接口。
但关键认知:RAG 的骨架没变——还是"检索什么 → 怎么拼 Prompt → 怎么约束 LLM 不幻觉"。向量库只是让"检索"这一步更聪明,RAG 框架没变。
六、最值钱的对比案例:Jaccard vs Embedding
用简历 RAG 做了三组对比测试:
| 问题 | Week 1 Jaccard | Week 2 Embedding | 结论 |
|---|---|---|---|
| "祥哥做过哪些项目" | ✅ 命中 "支付网关" | ✅ 命中 + 诚实说"其他未提及" | 都行,Embedding 更精确 |
| "祥哥会用 React 吗" | ✅ 命中"不擅长前端" | ✅ 命中,但倾向于正面回答 | 都行,但 Embedding 有时忽略否定词 |
| "祥哥的教育背景" | ❌ 翻车(字面零重合) | ✅ 命中"毕业211硕士" | Embedding 完胜 |
"教育背景"案例是精华:Jaccard 算交集,"教育"、"背景"跟"毕业"、"211"、"硕士"字面零重合,得分 0。Embedding 在 1024 维空间里理解到"教育背景"的语义接近"毕业于某211硕士"。
这就是"语义检索"的价值——不是向量库炫技,是检索这一步不再只看字面,而是看意思。
七、框架的边界:省了什么,没省什么
| 环节 | 手撕版 | 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 设计、检索策略)。
八、博客的"顿悟"时刻
对 Java 后端来说,Spring AI 最熟悉的地方是它的架构模式:
- ChatClient = JdbcTemplate 的 LLM 版
- @Tool = @RequestMapping 的函数版
- Advisor = HandlerInterceptor
- ChatMemory = 缓存接口(多种实现随意切换)
- VectorStore = JPA Repository 的向量版
没有一个是新东西。都是 Java 后端十多年前就在用的模式——模板方法、注解、拦截器、接口抽象、存储层切换。
变的只是上游变成了一个概率性 API——它不像数据库那样保证"输入 A 出 B",它会"输入 A 大概率出 B,偶尔出 C"。所以框架多做的一件事,是在不确定性上加护栏:ChatMemory 防遗忘、Tool Schema 防幻觉、ReAct 循环防跑偏、RAG 防编造。
九、下一个边界
- Week 3:多 Agent 协作 / LangGraph4j —— 当一个 Agent 搞不定的时候
- 生产级 RAG:Rerank、HyDE、分块策略优化
- 可观测性:自定义 Advisor 让 Framework 版也能吐中间步骤
总结
先手撕,再上框架。 一周手撕让你知道框架的底层是啥,一周 Spring AI 让你知道框架替你省了啥。
框架是省你时间的,不是省你理解的。 理解不能被框架封装。
一个 Java 老兵,写于 2026-07-19