Skip to content

手撕 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 应用大概就这几层:

  1. HTTP 调用层:往模型 API 发 POST 请求,带 JSON body,拿响应回来
  2. 对话记忆层:维护历史消息列表,每次都全量发给模型
  3. 工具调用层:告诉模型有哪些函数可用,检测模型的 tool_calls,执行函数,把结果回喂
  4. Agent 编排层:用循环驱动模型反复推理 + 调用工具,直到给出最终答案
  5. 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: resume
java
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

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1