和 AI 聊天,其实是一次 HTTP 请求
★导读:你发出去的每句话,最终都变成一个 JSON 包,POST 到某个服务器的接口上;AI 的回答,也不过是服务器返回的另一个 JSON。这篇文章把大模型 API 调用从头拆到尾——请求长什么样、响应怎么解析、流式怎么实现、Java 代码怎么写,读完你就不会再对着 API 文档发怵了。
01 · 先看一段"原始"的 AI 对话
平时我们用 ChatGPT、用 DeepSeek,看到的是一个漂亮的聊天界面。但把界面扒开,底下发生的事非常朴素:
你发一句话 → 程序把它包装成一个 HTTP 请求 → POST 到模型服务器 → 服务器生成回答 → 以 JSON 返回 → 程序解析展示。
就这么简单。真要说有什么特别的,也就是"你发的每句话都变成了一个 JSON"。下面这段 curl 就是一个完整的大模型调用,复制到终端里换上你的 Key 就能跑:
curl https://api.deepseek.com/chat/completions \ -H "Content-Type: application/json" \ -H "Authorization: Bearer sk-你的Key" \ -d '{ "model": "deepseek-v4-flash", "messages": [ {"role": "system", "content": "你是一个简洁的助手"}, {"role": "user", "content": "你好,用一句话介绍自己"} ], "temperature": 0.7, "stream": false }'看到没?没有魔法。就是 curl + 一个 POST + 一段 JSON。我们把这东西拆开,看看每一块都是干嘛的。
02 · 拆解请求:你发的那句话变成了什么
请求体是一个 JSON,核心字段就这几个:
{"model": "deepseek-v4-flash","messages": [ {"role": "system", "content": "你是一个简洁的助手"}, {"role": "user", "content": "你好"}, {"role": "assistant", "content": "你好!有什么可以帮你?"}, {"role": "user", "content": "介绍一下你自己"} ],"temperature": 0.7,"max_tokens": 2048,"stream": false}model | ||
messages | 完整对话历史 | |
role | ||
temperature | ||
max_tokens | ||
stream |
注意 messages 前面那个"完整"二字。这是整个 API 设计里最反直觉、也最重要的一点,下一节专门讲。
03 · 拆解响应:AI 的回答长什么样
请求发出去,服务器返回一个 JSON,长这样:
{"choices": [ {"message": {"role": "assistant","content": "你好!我是 DeepSeek,很高兴认识你。" },"finish_reason": "stop" } ],"usage": {"prompt_tokens": 12,"completion_tokens": 35,"total_tokens": 47 }}要拿的东西只有两处:
回答: choices[0].message.content——AI 说的话花费: usage.total_tokens——这次调用烧了多少 token(就是花了多少钱)
choices 是个数组而不是单个对象,是因为有的接口支持一次返回多个候选(n 参数)。日常使用直接取第 0 个就行。
04 · 原理一:为什么"多轮对话"要自己拼历史
这是新手最容易踩的坑,值得单独说。
大模型 API 是无状态的。 每一次调用,模型都像第一次见你——它不记得上一轮你们聊了什么。你看到的"它记得",全靠客户端(你的代码)把历史一字不差地塞进 messages 数组再发过去。
★类比:大模型像个"健忘的作家",你每次给它递一沓纸条,它只看这沓纸条。想让"对话"连续,你就得每次把之前所有纸条重新递一遍。
所以多轮对话的代码长这样:
// messages 就是"对话历史",由客户端自己维护List<Map<String, String>> history = new ArrayList<>();history.add(Map.of("role", "system", "content", "你是一个简洁的助手"));// 第 1 轮:用户提问history.add(Map.of("role", "user", "content", "你好,我叫小明,请记住我"));String r1 = callApi(history);history.add(Map.of("role", "assistant", "content", r1)); // 回答也追加进去// 第 2 轮:模型"记得"小明,因为历史里全都有history.add(Map.of("role", "user", "content", "我叫什么名字?"));String r2 = callApi(history); // 能答对:小明这个设计也解释了两个生产环境常见问题:
上下文超限:历史越长,请求越大。模型有上下文窗口上限(比如 128K token),超出就会报错。所以 Agent 框架都要做"历史压缩/裁剪"。 为什么无状态反而是好事:服务器不用维护海量会话,水平扩展毫无压力。把"记忆"外包给客户端,是工程上的聪明取舍。
05 · 原理二:Token——AI 世界的货币
usage 里的 token 到底是什么?
之前讲 LLM 原理时说过:模型不认文字,只认"词元"(token)。一句话会被分词器切成一串 token,模型按 token 逐个生成。粗略换算:
1 个英文字单词 ≈ 1 个 token 1 个汉字 ≈ 1~2 个 token 100 万 token ≈ 大约 75 万英文单词,或 60 万汉字
token 决定了三件事:
① 计费。 大模型按 token 收费:输入(prompt)和输出(completion)单价不同,一般是输入便宜、输出贵。你在响应里看到的 usage,就是这次调用的"账单"。
② 上下文窗口。 模型的"记忆力"上限用 token 数表示。messages 里的所有历史 + 生成的回答,都算在窗口里。128K 窗口听着大,塞上几十轮对话加几份文档,很快就见底。
③ 输出上限。max_tokens 限制模型最多生成多少 token——相当于给回答设了"页数上限"。不设的话,有的模型能一直写下去。
★顺手提一句:
max_tokens管的是"生成多少",不是"总共多少"。请求太长超限会直接报错,这是 RAG 应用里最常见的报错之一,到时候别慌,裁剪历史就行。
06 · 原理三:那些参数到底在干嘛
temperature、top_p 这些参数,上一期讲 LLM 原理时都埋过伏笔——它们控制的其实是"采样"这一步:模型算出每个 token 的概率分布后,怎么从这个分布里挑一个。
temperature | ||
top_p | ||
max_tokens | ||
stop |
一句话记忆:temperature 越低,模型越"怂",越只敢选最有把握的词;越高越"浪",敢选冷门的词。 想要稳定可靠,就压低;想要惊喜,就拉高。
07 · 原理四:流式输出(SSE)——打字机效果是怎么来的
现在把请求里的 "stream": false 改成 true,神奇的事情发生了。
非流式:模型憋 5 秒、10 秒,一次性把整段回答返回来。你的界面干等,转圈圈。
流式:模型每生成一个 token,服务器立刻推给你一个。你的界面一边收一边显示,效果就是 ChatGPT 那个"打字机"。
流式用的协议叫 SSE(Server-Sent Events),格式很朴素——一行一行 data: 开头的数据,直到 data: [DONE] 结束:
data: {"choices":[{"delta":{"role":"assistant"}}]}data: {"choices":[{"delta":{"content":"你好"}}]}data: {"choices":[{"delta":{"content":"!"}}]}data: {"choices":[{"delta":{"content":"很高兴"}}]}...data: [DONE]每个 data: 后面是一个 JSON,里面 choices[0].delta.content 就是这一段新生成的增量。把增量拼起来,就是完整回答。
为什么 Agent 应用几乎必须用流式?三个理由:
体验:几十秒的生成如果一次性返回,用户早就跑了 可观测:流式让你看到模型"正在干什么",出现异常能早点发现 可中断:用户在生成中途可以点"停止"——非流式做不到
08 · Java 实战:三种写法,从"裸调"到"一行"
方式一:原生 HttpClient,把原理写明白(理解为主)
不依赖任何 SDK,JDK 自带的 HttpClient 就够了。这也是理解 API 最好的方式:
import java.net.URI;import java.net.http.*;import java.net.http.HttpResponse.BodyHandlers;import com.fasterxml.jackson.databind.*;publicclassChatApiDemo{privatestaticfinal String URL = "https://api.deepseek.com/chat/completions";privatestaticfinal String API_KEY = System.getenv("DEEPSEEK_API_KEY");publicstaticvoidmain(String[] args)throws Exception { String body = """ { "model": "deepseek-v4-flash", "messages": [ {"role": "system", "content": "你是一个简洁的助手"}, {"role": "user", "content": "你好,用一句话介绍自己"} ], "temperature": 0.7 } """; HttpClient client = HttpClient.newHttpClient(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(URL)) .header("Content-Type", "application/json") .header("Authorization", "Bearer " + API_KEY) .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse<String> response = client.send(request, BodyHandlers.ofString());// 用 Jackson 解析:取回答 + 看花费 ObjectMapper mapper = new ObjectMapper(); JsonNode root = mapper.readTree(response.body()); String reply = root.path("choices").get(0) .path("message").path("content").asText();int totalTokens = root.path("usage").path("total_tokens").asInt(); System.out.println("AI:" + reply); System.out.println("本次消耗 token:" + totalTokens); }}30 行左右,一个能用的"AI 调用"就完成了。鉴权就是一个 Header(Authorization: Bearer),请求是一个 JSON,响应是一个 JSON——全文的核心就这三句话。
方式二:流式调用,做出打字机效果
把 "stream": true 加上,用 BodyHandlers.ofLines() 按行读 SSE,每来一行就打印增量:
HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(URL)) .header("Content-Type", "application/json") .header("Authorization", "Bearer " + API_KEY) .header("Accept", "text/event-stream") .POST(HttpRequest.BodyPublishers.ofString(body)) // body 里已带 "stream": true .build();client.send(request, BodyHandlers.ofLines()) // 按行读取 .body() .filter(line -> line.startsWith("data:")) // 只处理数据行 .forEach(line -> { String data = line.substring(5).trim();if (data.equals("[DONE]")) { System.out.println(); // 流结束return; }try { JsonNode node = new ObjectMapper().readTree(data); String delta = node.path("choices").get(0) .path("delta").path("content").asText(); System.out.print(delta); // 边收边打印 → 打字机 } catch (Exception ignored) { } });方式三:Spring AI,一行搞定
原理懂了之后,你会觉得框架真香。同样的功能,Spring AI 里就三行:
String reply = chatClient.prompt() .system("你是一个简洁的助手") .user("你好,用一句话介绍自己") .call() .content();流式也只要把 .call() 换成 .stream()。messages 的拼装、鉴权、JSON 解析、SSE 处理,框架全包了。
三种方式怎么选
我的建议:先亲手写一遍方式一,让"HTTP + JSON"的直觉长进肌肉里;正式项目用方式三。别一上来就只写框架——那样你永远不知道 messages 为什么要自己拼。
09 · 工程化:上线前要处理的四件事
API 调通只是开始。真到生产环境,这四个问题你迟早要面对:
① 重试与退避。 429(限流)、5xx(服务端故障)都是家常便饭。标准做法是指数退避:第一次失败等 1 秒,第二次 2 秒,第三次 4 秒……加一点随机抖动,防止一堆请求同时重试把服务打崩。前提是请求要"幂等"——重发同样的请求不产生副作用(大模型调用天然满足)。
② 超时与取消。 必须设置 connectTimeout 和 readTimeout,不然一次网络抖动就能挂住你的线程。用户关掉页面、点了停止,要能真正取消请求——流式场景尤其要处理好连接中断。
③ Token 成本监控。 每个响应里的 usage 字段是免费的"计量表"。上线后记日志、埋点,按月统计成本趋势。很多项目第一个月账单出来才发现"怎么烧了这么多钱"——token 用量和对话轮数直接挂钩,心里要有数。
④ 多模型切换。 OpenAI 兼容协议最大的价值就在这里:base-url 一换,代码不动。今天用 DeepSeek,明天想换通义、智谱,改个地址就行。Spring AI 里更绝,连 starter 依赖一起换掉,业务代码一行不改。
10 · 写在最后
把这篇看完,你其实已经掌握了 AI 应用开发的地基:
请求:一个 HTTP POST + JSON(model / messages / 参数) 响应:choices 里取内容,usage 里看成本 无状态:历史自己拼,这就是对话的本质 流式:SSE 逐 token 推送,打字机效果的真相 工程化:重试、超时、监控、换模型
说明:本文由作者撰写,仅供学习交流,代码基于 Spring AI 2.0 / Spring Boot 4 编写。
下一期聊聊【向量库 & Embedding】——这也是我们 AI Agent 学习路线的下一站。想继续的学习的点个【赞】和【推荐】让主编知道!顺手点个【关注】,感谢各位学习路上的朋友。
夜雨聆风