Skip to content

手撕 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)

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();

Spring AI 版(Week 2)

java
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,全量发出去。

java
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)

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 message 回喂

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 回喂(框架自动)。

这是 Week 1 到 Week 2 最直观的对比。手写 30 行 schema + 20 行分发逻辑,Spring AI 一行注解搞定。

但关键认知:手撕过才知道,Spring AI 的"自动"背后就是那 30 行 JSON + 20 行 switch。你知道它没在变魔术,只是把协议层样板代码抽象掉了。


四、Agent 编排 → 框架只做了一半

手撕版(Week 1):完整的 while 循环:

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;
    }
}

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 相似度,自己算交集/并集:

java
Set<String> intersection = new HashSet<>(queryWords);
intersection.retainAll(docWords);
double jaccard = (double) intersection.size() / union.size();

Spring AI 版:声明式接入 Qdrant:

yaml
spring:
  ai:
    vectorstore:
      qdrant:
        host: localhost
        port: 6334
        collection-name: resume
java
List<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 JaccardWeek 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

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。