一、LLM API 的本质:不是函数调用,是对话续写

Java 开发者第一次看 LLM API 文档,最困惑的是:为什么输入不是 String prompt,而是一个 messages 数组?为什么调同一个接口,有时候 3 秒返回、有时候 30 秒?为什么流式输出和普通调用是两套完全不同的体验?

理解这些问题的关键,是认识到 LLM API 和传统 REST API 的根本差异。不是"接口风格不同",而是底层处理机制完全不同。REST API 是"接收输入 -> 执行逻辑 -> 返回结果",LLM API 是"接收对话历史 -> 预测下一个 token -> 逐字返回"。这个差异决定了从参数设计到错误处理的每一个工程决策。

1. messages 数组:会话状态的序列化

# 传统思维:一个请求包含一个输入
response = llm.chat("帮我写一个订单确认邮件")

# LLM 实际模型:一个请求包含完整对话历史
response = llm.chat(messages=[
    {"role": "system", "content": "你是客服助手,只处理订单相关问题"},
    {"role": "user", "content": "帮我写一个订单确认邮件"},
    {"role": "assistant", "content": "好的,请问订单号是多少?"},
    {"role": "user", "content": "订单号 #20250701-001"}
])
# 输出: 尊敬的客户,您的订单 #20250701-001 已确认...

设计原理: LLM 的底层能力是"续写对话"。messages 数组完整记录了对话上下文–system 定义角色和规则,user 是用户输入,assistant 是模型之前的回复。这种设计让模型能在多轮对话中保持上下文一致性,而不是每次调用都从零开始。

Java 对照: 这和 REST API 的无状态设计不同。REST API 每次请求自包含所有信息,而 LLM API 的 messages 数组本质上是"把会话状态塞进请求体"。你可以理解为–每次调用都把整个 HttpSession 序列化后发过去。这意味着每次请求的 payload 会随着对话轮数增长,直接影响延迟和成本。

2. 流式输出的工程价值

# 非流式:等 5 秒,一次性拿到全部结果
response = client.chat.completions.create(
    model="your-model",
    messages=[{"role": "user", "content": "写一首关于编程的诗"}]
)
print(response.choices[0].message.content)
# 输出: 代码如诗,逻辑为韵...
# 流式:0.5 秒出第一个字,之后逐字流出
stream = client.chat.completions.create(
    model="your-model",
    messages=[{"role": "user", "content": "写一首关于编程的诗"}],
    stream=True
)
for chunk in stream:
    print(chunk.choices[0].delta.content or "", end="")
# 输出: 代(0.3s)码(0.4s)如(0.5s)诗(0.6s)...

性能对比:

模式 首字延迟 完整耗时 用户体验 适用场景
非流式 3-8s 3-8s 等待焦虑 后台批处理、数据抽取
流式 0.3-1s 3-8s 即时反馈 面向用户的聊天界面

工程分析: 流式输出不是可选项,而是生产环境的必修课。用户对首字延迟极其敏感–等 3 秒才开始输出,体验远差于 0.5 秒出第一个字、之后逐字流出。Spring AI 中对应的是 stream() 方法返回 Flux<String>,和 WebFlux 的响应式编程模型完美契合。但要注意流式模式下错误处理更复杂:如果第 5 个 chunk 时网络断了,已经返回的内容无法收回,需要设计幂等补偿机制。

二、5 分钟调通第一个 LLM API

1. 环境准备与最小可用代码

安装 SDK 只需要一行:

pip install openai
# 输出: Successfully installed openai-1.x.x

发送第一个请求:

from openai import OpenAI

client = OpenAI(api_key="your-api-key")
resp = client.chat.completions.create(
    model="your-model",
    messages=[{"role": "user", "content": "用一句话解释什么是RAG"}],
    temperature=0
)
print(resp.choices[0].message.content)
# 输出: RAG是一种结合检索和生成的技术,先从知识库找到相关文档,再让大模型基于这些文档生成回答。

就这些。 没有 Spring Boot 的启动类、没有 @Configuration、没有 Bean 注入。Python 的 AI 开发门槛就是这么低。但"能调通"和"能用在生产环境"之间差了十万八千里,后面的章节会逐步展开。

2. 请求结构详解

很多教程直接贴 JSON 原文,其实理解结构比看原始 JSON 更重要:

层级 字段 类型 说明 必填
顶层 model String 模型名称
顶层 messages Array 对话数组
顶层 temperature Float 随机性
顶层 max_tokens Int 最大输出
顶层 stream Bool 流式返回
顶层 top_p Float 核采样
messages[] role String system/user/assistant
messages[] content String 消息文本

2. 响应结构详解

层级 字段 说明 工程用途
顶层 id 请求 ID 日志追踪、工单排查
顶层 choices 回复列表 取回复内容
choices[] message.content 回复文本 核心输出
choices[] finish_reason 结束原因 判断是否截断
顶层 usage.prompt_tokens 输入 token 数 成本核算
顶层 usage.completion_tokens 输出 token 数 成本核算
顶层 usage.total_tokens 总 token 数 监控告警

关键字段说明: finish_reason 有三个值需要区分:

含义 工程处理
stop 正常结束 直接使用输出
length 被 max_tokens 截断 需要 continue 续写
content_filter 内容被安全过滤 记录日志并降级

Java 对照: 这和调用一个 RESTful API 没有本质区别。OpenAI SDK 底层就是 HTTP 客户端,帮你处理了序列化、重试、流式解析。Java 中用 OkHttp 或 WebClient 调用是一样的,只是代码多一些。

三、平台选型思路:云服务还是本地部署

1. 两种部署模式的本质差异

调通 API 之后,你会面临第一个工程决策:用云服务还是本地部署?这不是一个技术偏好问题,而是由业务约束驱动的工程决策。

维度 云服务 API 本地部署
初始成本 按量付费,近零门槛 GPU 硬件投入高
运维复杂度 平台托管,零运维 模型加载、显存管理、版本升级
延迟 网络往返 200-800ms 本地推理 50-200ms
并发能力 平台弹性扩容 受限于本地 GPU 数量
数据隐私 数据需发送到服务端 数据不出本地
模型选择 平台提供的模型列表 任意开源模型
可用性 依赖平台 SLA 依赖本地硬件稳定性
升级频率 平台自动更新 需手动下载新模型

选型原则: 生产环境根据数据隐私要求和延迟要求决定。对数据隐私要求高的场景(医疗、金融内部系统),本地部署是硬性约束;对响应速度要求高的场景,云服务的弹性扩容更可靠。

2. OpenAI 兼容协议:为什么切换成本这么低

一个好消息是,主流 LLM 平台和本地推理框架几乎都支持 OpenAI 兼容协议。这意味着你的业务代码只需要改一行配置就能切换平台:

# 云服务
client = OpenAI(api_key="your-key", base_url="https://api.example.com/v1")

# 本地部署(Ollama / vLLM 等推理框架)
client = OpenAI(api_key="not-needed", base_url="http://localhost:11434/v1")

# 后续调用代码完全一样,零改动切换
response = client.chat.completions.create(
    model="your-model",
    messages=[{"role": "user", "content": "你好"}]
)
print(response.choices[0].message.content)
# 输出: 你好!有什么可以帮助你的吗?

工程分析: 这就是 OpenAI 兼容协议的价值–生态锁定被打破了。你的业务代码不需要为每个平台写适配层。实际项目中建议把 base_urlmodel 放到配置文件里,通过环境变量切换,做到一套代码跑通开发、测试、生产三个环境。

3. 配置管理最佳实践

# config.py - 统一配置管理
import os

class LLMConfig:
    def __init__(self):
        self.base_url = os.getenv("LLM_BASE_URL", "http://localhost:11434/v1")
        self.api_key = os.getenv("LLM_API_KEY", "not-needed")
        self.model = os.getenv("LLM_MODEL", "your-model")
        self.temperature = float(os.getenv("LLM_TEMPERATURE", "0.7"))
        self.max_tokens = int(os.getenv("LLM_MAX_TOKENS", "1000"))
        self.timeout = int(os.getenv("LLM_TIMEOUT", "30"))

config = LLMConfig()
client = OpenAI(api_key=config.api_key, base_url=config.base_url)
print(f"连接到: {config.base_url}, 模型: {config.model}")
# 输出: 连接到: http://localhost:11434/v1, 模型: your-model

Java 对照: 这和 Java 项目中用 Spring Profile 管理不同环境的数据库连接是一个思路。application-dev.yml 指向本地模型,application-prod.yml 指向云服务 API,代码里注入的是同一个 ChatClient 接口,不关心底层用的是哪个平台。

四、Java 版核心代码

Java 开发者如果暂时不想碰 Python,用 Java 也能调通:

// Spring AI 方式(第07篇详讲)
// pom.xml 添加 spring-ai-openai-spring-boot-starter

// application.yml 配置
// spring.ai.openai.api-key=${LLM_API_KEY}
// spring.ai.openai.base-url=${LLM_BASE_URL}
// spring.ai.openai.chat.options.model=${LLM_MODEL}

ChatClient client = ChatClient.create(model);
String answer = client.prompt()
    .user("用一句话解释什么是RAG")
    .call().content();
System.out.println(answer);
// 输出: 和 Python 版结果一致

Java 对照: Python 版用了 7 行代码完成的事,Spring AI 压缩到了 3 行。但 Spring AI 的配置背后还有一套自动装配逻辑。Python 的显式调用在调试时更直观,Java 的声明式配置在生产环境更规范。两种方式各有优势,建议先 Python 理解原理,再 Java 落地工程。

五、多轮对话的工程实现

单轮调用只是起点。真实业务场景中,用户通常会连续提问,需要 AI 保持上下文。多轮对话是 LLM 应用最常见也最容易出错的场景。

1. 最简多轮对话

from openai import OpenAI

client = OpenAI(api_key="your-key")
messages = [
    {"role": "system", "content": "你是一个订单管理系统的客服助手"}
]

# 第一轮
messages.append({"role": "user", "content": "查询订单 #20250701-001 的状态"})
response = client.chat.completions.create(
    model="your-model", messages=messages, temperature=0
)
ai_reply = response.choices[0].message.content
messages.append({"role": "assistant", "content": ai_reply})
print(f"AI: {ai_reply}")
# 输出: AI: 订单 #20250701-001 当前状态为已发货,预计明天到达。
# 第二轮 - AI 能记住上一轮的对话
messages.append({"role": "user", "content": "那大概几点能到?"})
response = client.chat.completions.create(
    model="your-model", messages=messages, temperature=0
)
print(f"AI: {response.choices[0].message.content}")
# 输出: AI: 根据物流信息,预计明天下午 2-5 点之间送达。

2. 多轮对话的状态管理

问题 症状 原因 解决方案
上下文丢失 AI 忘记之前说的内容 messages 未带历史 每轮都带完整 messages
token 超限 报错 context_length_exceeded 消息累积过多 截断旧消息
成本递增 每轮都比上轮贵 输入 token 线性增长 控制保留轮数
角色混乱 system 提示被覆盖 system 位置不对 system 始终在第一行
响应变慢 第 10 轮明显比第 1 轮慢 输入变长导致推理变慢 限制上下文长度

工程分析: 每多一轮对话,messages 数组就多两条消息(user + assistant),输入 token 线性增长。到第 20 轮时,输入可能是第一轮的 10 倍。生产环境必须实现"滑动窗口"策略:保留 system 消息 + 最近 N 轮对话,丢弃更早的。

3. 滑动窗口实现

from collections import deque

class ChatSession:
    def __init__(self, system_prompt, max_history=10):
        self.system = {"role": "system", "content": system_prompt}
        self.history = deque(maxlen=max_history * 2)  # 每轮 2 条消息

    def add(self, role, content):
        self.history.append({"role": role, "content": content})

    def get_messages(self):
        return [self.system] + list(self.history)

# 使用
session = ChatSession("你是客服助手", max_history=5)
session.add("user", "订单 #001 到哪了?")
session.add("assistant", "已发货,明天到。")
session.add("user", "可以改地址吗?")
# 第 6 轮后,最早的消息自动被 deque 丢弃

Java 对照: 这和 Java 中用 LinkedHashMap 实现 LRU 缓存是同一个思路–固定容量,超出就淘汰最旧的。区别在于 LLM 场景下淘汰的是对话历史,不是缓存条目,需要考虑"被淘汰的消息是否包含关键信息"的问题。后续篇章会讲用 RAG 解决这个问题。

六、错误处理与重试策略

1. 常见报错速查

报错信息 HTTP 状态码 原因 解决方案
Unauthorized 401 API Key 错误或过期 检查环境变量是否正确加载
Rate Limit 429 请求频率超限 加重试退避(exponential backoff)
context_length_exceeded 400 输入 token 超限 截断历史消息或换更大窗口模型
Connection refused - 网络不通/服务未启动 本地部署检查服务是否运行
model_not_found 404 模型名拼写错误 核对平台文档的模型 ID
Internal Server Error 500 平台内部错误 重试,持续报错切备用平台
Empty response 200 max_tokens 设太小 调大 max_tokens

2. 生产级重试封装

import time
from openai import OpenAI, RateLimitError, APIConnectionError

client = OpenAI(api_key="your-key")

def call_llm_with_retry(messages, max_retries=3):
    """带指数退避的重试封装"""
    for attempt in range(max_retries):
        try:
            response = client.chat.completions.create(
                model="your-model",
                messages=messages,
                temperature=0.7,
                timeout=30
            )
            return response.choices[0].message.content
        except RateLimitError:
            wait = 2 ** attempt
            print(f"限流,{wait}s 后重试 ({attempt+1}/{max_retries})")
            time.sleep(wait)
        except APIConnectionError as e:
            print(f"连接失败: {e}")
            time.sleep(1)
    raise Exception(f"重试 {max_retries} 次后仍失败")

重试策略对比:

策略 实现 优点 缺点
固定间隔 sleep(2) 简单 可能加重限流
指数退避 2^attempt 自适应 首次等待可能过长
指数+抖动 2^attempt + random 分散重试 实现稍复杂
令牌桶 限流器 主动控制 需额外组件

Java 对照: 这些报错和 Java 中的 HttpClient 异常没有本质区别。401 相当于认证失败,429 相当于限流。Spring Retry 框架提供了 @Retryable 注解实现同样的指数退避逻辑,配置式而非编程式。

七、API 调用验证检查清单

基础验证

检查项 验证方式 通过标准
API Key 有效性 发送测试请求 返回 200 + 有效内容
网络连通性 curl 基础接口 响应时间 < 3s
模型可用性 指定 model 参数 返回非空 content
Token 消耗记录 检查 usage 字段 input/output token 合理
错误处理 模拟无效 Key 优雅降级而非崩溃
超时设置 设置 timeout 参数 30s 内超时返回
流式输出 stream=True 测试 逐 token 返回

生产环境额外检查

检查项 说明 验证方式
重试机制 429 错误时指数退避 模拟限流测试
日志脱敏 API Key 不出现在日志 grep 日志文件
成本监控 按 usage 统计每日消耗 设置预算告警
降级方案 主平台不可用时切备用 模拟平台故障
并发控制 控制请求并发数 压力测试
优雅关闭 等待进行中的请求完成 发送 SIGTERM
输出校验 检查返回内容格式 JSON schema 验证

八、踩坑总结

坑点 症状 原因 解决方案
API Key 写在代码里 代码提交后泄露 硬编码 用 .env + python-dotenv
不处理超时 请求挂起无限等待 默认无超时 设置 timeout=30
忽略 finish_reason 输出被截断但不知道 max_tokens 太小 检查是否为 length
messages 顺序错误 模型回答不知所云 system 位置不对 system 在前,user 在后
不记录 token 消耗 月底账单惊吓 缺少监控 每次调用记录 usage
流式不处理空 chunk 程序报错 NoneType delta.content 为 None or "" 兜底
多轮对话不做截断 突然报错超限 消息累积超 context 实现滑动窗口
system 消息被截断 AI 角色混乱 截断时误删 system 截断只针对 user/assistant

最高频坑点详解: 多轮对话不做截断是最常见的生产事故。假设每轮对话平均 300 token,第 15 轮时输入就达到 4500 token,第 50 轮就可能超过小窗口模型的限制。解决方案是在每次调用前检查 messages 总 token 数,超过阈值的 80% 就开始从最早的非 system 消息截断。注意:system 消息永远不能被截断,否则 AI 的行为会完全失控。

九、完整示例:封装一个生产可用的 LLM 客户端

把前面所有知识点整合起来,封装一个实际可用的 LLM 客户端类。这个类包含配置管理、多轮对话、滑动窗口、重试机制和成本统计:

import os
import time
import tiktoken
from openai import OpenAI, RateLimitError, APIConnectionError
from collections import deque

class ProductionLLMClient:
    """生产可用的 LLM 客户端封装"""

    def __init__(self, config=None):
        self.config = config or self._load_config()
        self.client = OpenAI(
            api_key=self.config["api_key"],
            base_url=self.config["base_url"]
        )
        self.encoding = tiktoken.get_encoding("cl100k_base")
        self.total_tokens = 0
        self.total_calls = 0

    def _load_config(self):
        return {
            "base_url": os.getenv("LLM_BASE_URL", "http://localhost:11434/v1"),
            "api_key": os.getenv("LLM_API_KEY", "not-needed"),
            "model": os.getenv("LLM_MODEL", "your-model"),
            "temperature": float(os.getenv("LLM_TEMP", "0.7")),
            "max_tokens": int(os.getenv("LLM_MAX_TOKENS", "1000")),
            "timeout": int(os.getenv("LLM_TIMEOUT", "30")),
        }

    def count_tokens(self, text):
        return len(self.encoding.encode(text))

    def chat(self, messages, temperature=None):
        """带重试的聊天调用"""
        temp = temperature if temperature is not None else self.config["temperature"]
        for attempt in range(3):
            try:
                resp = self.client.chat.completions.create(
                    model=self.config["model"],
                    messages=messages,
                    temperature=temp,
                    max_tokens=self.config["max_tokens"],
                    timeout=self.config["timeout"]
                )
                self.total_tokens += resp.usage.total_tokens
                self.total_calls += 1
                return resp.choices[0].message.content
            except RateLimitError:
                time.sleep(2 ** attempt)
            except APIConnectionError:
                time.sleep(1)
        raise Exception("LLM 调用失败,重试耗尽")

    def get_stats(self):
        return {"calls": self.total_calls, "tokens": self.total_tokens}
# 使用示例
client = ProductionLLMClient()
reply = client.chat([
    {"role": "system", "content": "你是 Java 技术专家"},
    {"role": "user", "content": "Spring Boot 启动流程是什么?"}
])
print(reply)
# 输出: Spring Boot 启动流程主要包括...

print(client.get_stats())
# 输出: {'calls': 1, 'tokens': 850}

封装要点总结:

封装层 职责 关键点
配置管理 环境变量加载 12-Factor 规范
重试机制 指数退避 最多 3 次
Token 统计 成本监控 记录 usage
超时控制 避免无限等待 默认 30s
编码器 Token 计算 cl100k_base

Java 对照: 这个封装对应 Java 中的一个 @Service 类,注入 ChatClient 和配置,对外暴露 chat() 方法。Spring AI 的 ChatClient 已经内置了重试和超时配置,但 Token 统计需要自己实现。Spring Boot Actuator 的自定义 metrics 可以用来暴露 Token 消耗到 Prometheus + Grafana 监控面板。

十、测试验证:确保 API 调用可靠性

LLM API 的测试和传统 API 测试不同:输出是不确定的(temperature > 0 时),你不能断言返回的具体内容。测试策略需要调整为验证"结构"和"行为"而非"内容"。

1. 测试维度划分

测试类型 验证目标 通过标准 工具
连通性测试 API 可达 返回 200 pytest
格式测试 响应结构正确 choices[0] 非空 pytest
参数测试 temperature 生效 t=0 两次输出一致 pytest
边界测试 超长输入处理 优雅报错而非崩溃 pytest
性能测试 响应时间 P95 < 5s locust
成本测试 Token 消耗合理 单次 < 预算阈值 手动

2. 基础测试代码

import pytest
from your_module import ProductionLLMClient

@pytest.fixture
def client():
    return ProductionLLMClient()

def test_api_connectivity(client):
    """测试 API 连通性"""
    reply = client.chat([{"role": "user", "content": "hello"}])
    assert reply is not None
    assert len(reply) > 0

def test_temperature_zero_reproducibility(client):
    """temperature=0 时两次输出应一致"""
    msg = [{"role": "user", "content": "1+1="}]
    r1 = client.chat(msg, temperature=0)
    r2 = client.chat(msg, temperature=0)
    assert r1 == r2

def test_token_counting(client):
    """Token 统计功能正常"""
    tokens = client.count_tokens("你好世界")
    assert tokens > 0
    assert tokens < 10

测试输出示例:

$ pytest test_llm.py -v
test_api_connectivity PASSED
test_temperature_zero_reproducibility PASSED
test_token_counting PASSED
3 passed in 8.42s

Java 对照: 这和 Spring Boot Test 中用 @SpringBootTest 注入 Service 做集成测试是一个思路。区别在于 LLM 测试需要真实 API 调用(无法 Mock 模型行为),建议在 CI 中标记为 @Tag("integration") 单独运行,不阻塞主流程。

十一、从 API 调用到生产应用的路线图

阶段 目标 关键能力 对应篇章
调通 API 能发送请求拿到回复 SDK 使用、参数理解 本文(第03篇)
Prompt 工程 稳定控制输出格式 结构化提示词、变量插值 第05篇
函数调用 让 AI 调用外部工具 Function Calling 第06篇
RAG 检索 基于知识库回答 Embedding + 向量检索 第10-11篇
Agent 架构 多步骤自主执行 ReAct、Plan-Execute 第19篇
生产部署 稳定可靠上线 限流、降级、监控 第26篇

工程分析: 从调通 API 到生产部署,技术跨度远大于从 Hello World 到 CRUD。LLM 应用开发不是"调个接口"那么简单,而是涉及提示词工程、检索增强、工具调用、上下文管理、成本控制、安全防护等多个工程维度。每一步都有坑,每个坑都可能让线上系统出问题。后续篇章会逐个深入这些主题。

下一篇预告

下一篇我们进入 LLM 应用开发的核心能力–Prompt Engineering(提示词工程)。不是教你写"你是一个专业的 XX"这种模板,而是讲透提示词的结构化设计、变量插值、以及如何用 Java 开发者的工程思维来管理提示词版本。


这是《Java 程序员的 AI 进阶之路》系列第 03 篇。调通 API 只是起点,真正的挑战在后面。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐