Java 项目里第一次接大模型接口,多半卡在这几处:用哪个 HTTP 客户端、请求体怎么拼、流式响应怎么读、字段解析老是对不上、跑起来又遇到超时和并发限制。这篇不聊协议本身,只讲工程落地:一份能跑通的调用骨架,加上超时、重试、降级、日志和上线前的自检清单,照着改就能接进你现有的服务里。
Java 侧用哪个 HTTP 客户端发这个请求
不绕弯子:先去 pom 里翻一眼,你现在用什么 HTTP 客户端,新代码就跟着用那个。为了调一个接口再引一套依赖,多半是给自己找麻烦——多一份连接池配置、多一套日志埋点、多一个要盯的安全公告。
| 方案 | 什么时候选它 | 什么情况下不适用 |
|---|---|---|
| JDK 自带 HttpClient | 普通服务端项目,想零依赖、要异步或流式读 | JDK 太老,或者团队已经在用别的客户端统一管连接池 |
| OkHttp | 想要简洁接口、连接池和拦截器开箱可用 | JDK 自带客户端已经能覆盖,只调一个接口就够 |
| Apache HttpClient | 企业项目里存量多,和现有依赖栈一致 | 新项目从零起步,没有历史包袱 |
| Spring 的 RestClient / WebClient | Spring 项目,想复用已有的序列化与超时配置 | 非 Spring 环境,或者团队对 WebClient 的响应式模型不熟 |
- 零依赖:不用往构建文件里加东西,少一层供应链风险
- 异步和流式:内置 API 就能发异步请求、按行读流式返回
- 没有连接池心智负担:默认配置够用,不需要额外调参
- 代价是:连接池、重试策略这些要自己写,拦截器能力不如 OkHttp 顺手
- 先看 JDK 版本用 java -version 确认。只要不是特别老的版本,优先考虑自带 HttpClient,别一上来就引第三方。
- 再看要不要流式如果调用要边收边处理(比如长回答、要拿首字延迟),确认所选客户端能按行读响应体,而不是一次性读完再返回。
- 最后看依赖栈翻构建文件里已有的 HTTP 客户端。已经有一个在跑,就复用它,把超时、连接池、日志按老规矩配好。
选型的三个判断点就摆在桌面上:要不要流式、要不要自己管连接池、团队现有依赖是什么。这三点定下来,方案基本就唯一了,不用反复纠结。想看看同类接口实际该怎么超时和重试,可以去站内的指南中心翻一翻相关条目,对照着改自己的配置。
下一步把你的客户端方案和这三个判断点对一遍,再去站内工具目录挑一个请求调试工具实测一次,比纯看文档靠谱。→请求体怎么拼:别手写字符串
手写 JSON 请求体是 Java 调大模型接口最常见的翻车点。漏个逗号、中文没转义、引号嵌套错一层,接口直接返回解析失败,你还得对着日志一行行数括号。正确做法是定义一个跟请求体结构对应的 POJO,把字段填好,交给 Jackson 的 ObjectMapper 序列化,让库去管逗号和转义。
- 模型名
- 字符串字段,从配置或常量取,别散落在业务代码里
- messages
- 消息数组,每项含角色和内容两个字段,顺序即对话顺序
- 流式开关
- 布尔字段,决定要不要边生成边返回,按前端能力选
- 调参字段
- 温度、最大输出长度等,不传就走服务端默认值
| 常见错误 | 后果 | 怎么避免 |
|---|---|---|
| 手拼字符串漏逗号 | 请求体解析失败 | 用 POJO + Jackson 序列化 |
| 中文或引号没转义 | 内容被截断或报错 | 交给序列化器处理 |
| 把整个对话拼成一个大字符串 | 模型分不清谁说了什么 | messages 数组按轮次追加 |
| 调参字段随手加 | 输出风格漂移、结果不稳 | 先明确改动理由再传值 |
| 模型名写死 | 模型下线后调用报错 | 做成配置项,定期核对口径 |
- 定义请求体 POJO把模型名、消息列表、流式开关写成字段,消息项单独一个类,包含角色和内容。
- 用 Jackson 序列化new ObjectMapper().writeValueAsString(request),不要自己拼字符串。
- 多轮对话追加历史每次新提问前把上一轮的用户和助手消息按顺序加进列表,别合并成一段。
- 按需加调参字段温度、最大输出长度这类字段用包装类型,不设置就不序列化,走默认值。
- 把易变值抽成配置模型名和可用参数放到配置文件或常量类,改的时候不用翻业务代码。
- 序列化前打印一次最终 JSON,肉眼确认结构和字段名
- 对 messages 用 List 而不是拼接字符串,顺序天然正确
- 调参字段用 Integer/Double 而非基本类型,null 时才不会被序列化
- 模型名、参数范围这类数字去 /models 和 /official-api/providers/deepseek 核对当时的口径
- 把请求体构造抽成一个方法,方便单测直接断言生成的 JSON
响应解析:字段对不上时的定位顺序
字段对不上,十次里有八次不是接口变了,是解析方式跟响应的实际结构拧着。先记住一个判断:这类接口的返回体是两层——顶层放元信息(这次用的哪个模型、token 用量的明细),真正要的内容在 choices 这个数组里,而且是嵌在数组元素内部的字段。一上来就满层找那个字符串字段,等于给自己挖坑。
- 先分层看结构把响应体打印成格式化 JSON,肉眼确认三层:顶层对象、choices 数组、数组元素里的内容字段。确认完再动手写映射,而不是边试边改。
- 再抽出解析类写一个 ResponseParser,只干一件事:把原始响应映射成业务要的结果对象。对外只暴露「文本」和「用量」两个字段,其余结构全部封在类内部。
- 最后加兜底与日志字段读取用 Optional 或默认值,不要直接 get 后使用;结构不符合预期时,先把原始响应体打进日志,再抛出带上下文的自定义异常。
| 现象 | 大概率原因 | 先查什么 |
|---|---|---|
| 拿不到内容文本 | 字段路径写错,或把顶层当成了内容层 | choices 数组元素下的内容字段路径 |
| 报空指针 | 字段缺失时直接取值,没做兜底 | 取值处是否用了 Optional 或默认值 |
| JSON 解析直接抛异常 | 响应体不是完整 JSON,被截断 | 原始响应体的首尾字符是否配对 |
| 某些元信息读不到 | 客户端配置忽略了未知字段 | 反序列化配置里未知字段的处理策略 |
- 解析和业务拆开:接口字段变了,只改 ResponseParser 一个类,业务层不动
- 字段缺失别裸取:Optional 或默认值是底线,避免线上只看到一句 NPE
- 异常要带原始响应体:否则排查时手里只剩一句报错,没有现场
- 未知字段先别急着忽略:配置把没见过的字段吃掉,会让你误以为字段不存在
- 日志分级要克制:原始响应体只在解析失败时打,正常路径别全量刷
- 什么情况不适用:如果用的是官方 SDK 自带的响应对象,优先按 SDK 的字段名取,别自己再包一层映射
这套顺序的价值在于把问题切成互不干扰的三段,每段都能单独验。用得顺手之后,字段对不上基本就是几分钟的事。想核对不同接口当前的字段结构和用量口径,到站内的模型资料库页面看当时的说明;各家的调用方式和参数差异,可以对照 API 价格与模型资料页里对应的条目。
流式响应该怎么读
先记住一件事:流式接口吐回来的不是一整块 JSON,而是一行行事件片段,服务端生成一点推一点。所以客户端不能用「等响应体收完再整体反序列化」的思路,否则要么解析报错,要么等到最后才拿到东西,等于白开流式。
- 按行读,不是按块读用 BufferedReader 的 readLine,或者按 \n 切分输入流。每读到一行就当一条独立事件处理,不要试图把整段响应拼成一个字符串再解析。
- 跳过噪声行空行直接丢;以冒号开头的注释行丢;遇到结束标记(常见写法是 data: [DONE] 这类)就跳出循环、关流。这些都不该进解析器。
- 去掉前缀再解析每行通常带一个固定前缀(data: 之类),先剥掉前缀、trim 空白,剩下的才是 JSON。再用 Jackson / Gson 单行反序列化,只取里面的增量字段。
- 增量追加到缓冲区把每段文本 append 到 StringBuilder 或直接推到前端。别在中间层做任何「等攒够几句再发」的合并,那会让流式退化成块式。
排障时有两个时间点比总耗时值钱:一个是「首字到达时间」,也就是第一个非空增量到手的时刻;另一个是「整段耗时」,从发起请求到流结束。前者卡住通常是连接、鉴权或队列排队的问题,后者偏长一般是模型生成慢或输出太长。只看总耗时,这两类问题分不开。
| 现象 | 先怀疑哪里 | 怎么验证 |
|---|---|---|
| 首字迟迟不来 | 网关/代理缓冲、连接没建立 | 在读取第一行前打时间戳,看是不是长时间没有可读数据 |
| 首字很快但后面断断续续 | 模型生成侧按 token 推,属正常 | 对比首字和整段耗时,看是否只是输出长 |
| 全程无增量、最后一次性全到 | 中间层开了响应缓冲 | 换个不经过代理的直连地址试一次,行为立刻不同就基本确认 |
| 流中途断开 | 超时设置、连接被中间层掐断 | 看断开位置是否固定在某个时长附近 |
- 检查反向代理是否开了响应缓冲(Nginx 一类默认可能缓存上游响应),要显式关掉
- 检查应用框架有没有对响应做统一包装或压缩,压缩往往会强制攒够一个块再发
- 检查客户端 HTTP 库是否默认缓存响应体,有些封装会把流当普通 body 读
- 用命令行直接请求一次,排除代码层干扰,看原始流是不是一行行冒出来的
- 确认没有在中途调用会把流读完的便捷方法,那等于把流式当同步用
另外,拼接逻辑放客户端意味着你要自己处理重连和去重。如果中途断开后重试,前面的缓冲区内容要保留还是丢弃,得提前定好规则,否则用户会看到重复的段落。这个坑不踩一次很难想到,建议正式接入前先在本地用一个长输出请求压一遍。
超时、并发和失败重试怎么设
Java 里调 API,最容易踩的坑就是只配一个「总超时」。这么干的结果通常是:网络握手慢的时候把时间全耗在等连接上,真正读数据反而没时间;长文生成任务又老是卡在读取阶段被砍断。合理做法是把时间拆成三层分别设,每层管自己的事。
| 层次 | 管什么 | 怎么定 | 不适用的情况 |
|---|---|---|---|
| 建连超时 | TCP 握手 + TLS 协商 | 给短一点,几秒量级足够,超过说明网络或 DNS 有问题 | 走内网专线或同机房调用,可以更短 |
| 读取超时 | 发出请求到拿到完整响应 | 按任务分档:短问答、长文生成、带工具调用三类各设一档 | 流式接口要按「两个 chunk 之间的间隔」单独设,不能套用整体时长 |
| 整体超时 | 单个请求从头到尾的上限 | 兜底用,必须大于建连 + 读取之和 | 批量任务里如果单条超时会影响整批结果,需要按批再算一次 |
读取超时的分档不是拍脑袋,得看你这条链路的实际延迟。同一个接口,短问答和长文生成的返回时间可能差一个数量级,带工具调用的任务还会因为多轮往返更久。分档前先到 测速与延迟基线页面(/tools/speed-test) 跑几遍,把你自己的 P50 / P95 量出来,再在 P95 上加一点余量当前两档的读取超时。别照抄别人文档里的数字,链路不一样。分档和限流触发后的处理思路,并发与限流分档指南(/guides/concurrency-throttle-by-tier) 里讲得比较细,值得先过一遍再动手改配置。
并发得有上限,超了就排队
- 设一个信号量或固定线程池用 Semaphore 限制同时在跑的请求数,或者用固定大小的线程池。这个数不是越大越好,先按你的配额和机器能扛的连接数定一个保守值。
- 把超出的请求放进有界队列队列要有长度上限,满了就明确拒绝或降级,不要无限堆积。无限堆积的后果是内存涨上去、延迟雪崩,最后整批任务一起失败。
- 观察排队时长队列等待时间要单独打点。如果等待时间持续接近你的读取超时,说明并发上限设小了,或者上游确实慢,这时候调大并发反而更糟。
不设并发上限最典型的症状就是:一开始跑得挺快,跑到某个量级突然大面积报错,一看全是限流或连接被拒。因为瞬时并发把配额打满,服务端开始丢请求,而你的任务里每条都算独立失败,于是整批结果都不可用。加上限之后,慢是慢一点,但结果是稳定的。
重试只对值得重试的错误做
- 可以重试
- 连接超时、读超时、网关类 5xx、限流返回。这类是暂时性的,退避后重试有意义。
- 不要重试
- 鉴权失败、参数校验失败、资源不存在、模型名写错。这些重试多少次结果都一样,只会把真正的问题往后拖。
- 退避方式
- 指数退避加随机抖动,避免同一批请求在同一个时间点集中重试,再次把配额打满。
- 重试上限
- 设死次数和总时长两个上限,任何一个先到就放弃,别让一条请求无限循环。
实现上建议把「哪些异常可重试」写成一个判断方法,而不是在每个调用点重复 if。这样以后错误码变了只改一处。重试日志里要带上第几次重试、原始错误类型、耗时,不然线上排查只能靠猜。
- 先把三层超时写进配置,不硬编码在代码里,方便按环境调
- 读取超时按短问答 / 长文 / 工具调用分三档,各档独立可改
- 并发上限用信号量或固定线程池,队列必须有界
- 可重试错误清单单独维护,退避加抖动,双上限收口
- 上线前到站内测速页面量一遍基线,改完再量一遍对比
降级与日志:出问题时能查得动
降级最怕写成「感觉不行就切」。得先把触发条件写死在配置里:连续失败几次、单次延迟超过多少毫秒、错误码落在哪个区间,三者满足其一才动作。没有阈值的降级等于没降级,出了问题只能靠人拍脑袋。
| 触发信号 | 建议阈值来源 | 切换目标 |
|---|---|---|
| 连续失败次数 | 本地配置常量,如连续 N 次非业务类错误 | 备用模型或备用接入点 |
| 单次请求耗时 | 取正常 P95 的倍数,别写具体数字 | 低延迟备用通道 |
| 错误码类别 | 限流、鉴权、超时分别处理 | 限流走备用接入点,鉴权先告警不切 |
| 熔断窗口 | 滑动窗口内失败比例过高 | 整体切备用,同时打点 |
- 切换后要记录切换事件,否则复盘时分不清是主路径慢还是备用路径更慢
- 备用路径的响应要单独打标,别和主路径混在同一个指标里
- 降级只覆盖可重试类错误,业务参数写错这类不该触发切换
- 回切条件也要定:连续成功多少次或冷却多久才回主路径
- 备用模型能力有差异,切过去后要接受结果格式可能不同,解析层提前留兼容
日志字段别乱记,缺一个排查时就得多问一圈人。最少要有请求 ID、目标地址、模型名、耗时、状态码、token 用量、是否重试。更细的记法可以直接照站内 /guides/api-log-fields-replay 那份字段清单来对齐,省得自己发明一套。
- 先定字段按上面的最小集在日志里落一份结构化字段,别拼成一行字符串,方便后面按请求 ID 检索
- 再定脱敏提示词和用户数据整段不要落盘,只留字符长度和哈希值,原文放独立的受控存储
- 最后验证用一次人工触发的失败请求,去日志里按请求 ID 捞,确认字段齐全、能还原出调用路径
接入后先跑这几项自检
代码能编译、请求能发出去,只说明接线没错,不代表链路是通的。接入之后别急着写业务逻辑,先按下面四项各跑一遍,重点不是「跑通」,而是「在出问题时能不能看懂发生了什么」。顺序建议从单请求开始,越往后越接近真实流量形态。
- 先跑一条最短的同步请求参数只用必填项,消息体就一句话,目标是拿到一段正常返回文本。确认拿到之后,把返回对象的每个字段打印出来看一遍,搞清楚哪些字段是内容、哪些是 token 用量、哪些是结束原因。这一步过了再往上面叠业务逻辑,否则后面报错你分不清是接线错了还是业务代码错了。
- 故意制造三类异常各跑一次错的密钥、错的模型名、超长输入,这三类分别跑一遍,观察抛出的是什么。要看的是:异常类型能不能区分、错误信息里有没有可读的原因、有没有把原始响应体吞掉。如果三类异常最后都变成同一句「调用失败」,说明异常分支等于没写,线上排查会很痛苦。
- 按预期峰值并发压一小批不用压测工具做全量,按你预估的峰值并发发一小批请求就够。重点看三件事:有没有触发限流、有多少请求超时、连接池够不够用。连接池偏小会表现为大量请求卡在等待连接而不是卡在网络,这个现象要能分辨出来。
- 把响应体固化成解析用例拿几条真实返回,把响应体存成固定样本,写成一个解析测试。以后上游字段结构变了,这个测试会先红,而不是等线上某个字段取到空值才有人发现。样本至少覆盖正常返回和一条异常返回。
| 自检项 | 观察什么 | 不通过时的典型表现 |
|---|---|---|
| 单请求链路 | 有没有拿到正常文本,字段是否齐全 | 内容为空或字段缺失,业务代码拿到 null |
| 异常分支 | 三类错误能否抛出可区分、可读的信息 | 所有错误都变成同一句「调用失败」 |
| 并发压测 | 限流、超时、连接等待各自占比 | 大量请求卡在等连接而非等网络 |
| 解析回归 | 字段结构变化能否被测试先捕获 | 线上取到空值才被发现 |
- 异常自检只跑一次不够,改完错误处理要再跑一遍,确认信息真的变可读了
- 并发那一轮不用追求跑满,够触发一次限流或一次超时就有诊断价值
- 响应体样本不要手写,直接从真实返回里拷,手写的样本测不出真实结构
- 这四项都过了再合并业务代码,顺序反过来会把两类问题混在一起排查
常见问题
Java 调用需要额外的 SDK 吗?
不需要。DeepSeek 走的是 HTTP 接口,任何能发 POST 请求的客户端都能调,JDK 自带的 HttpClient 就够了。引 SDK 的唯一好处是省掉请求体拼装,但代价是多一层依赖和版本跟进,内部小项目直接用标准 HTTP 客户端更可控。
响应里的内容字段取不到,是什么原因?
按这个顺序查:先确认拿到的响应体是不是完整 JSON(有没有被网关截断或包了一层),再确认字段路径写对没有,内容通常在 choices 数组的第一项里;最后检查反序列化配置是不是把未知字段忽略掉了,导致实际存在的字段没映射进来。
流式和非流式该选哪个?
面向用户实时展示的场景用流式,体验差别明显;后台批处理、结果还要再加工的链路用非流式,代码简单、异常处理也省事。如果不确定,先按非流式把链路跑通,需要时再加流式分支,不要一上来就两套都写。
并发一高就报错怎么处理?
先确认报的是限流还是连接层面的错误,两者处理方式不同。限流要给请求排队并加退避重试,连接错误要检查连接池和超时配置。别用无上限的线程池硬冲,那只会把失败比例放大,具体分档做法可以看站内并发相关的指南。
本地能跑通,部署到服务器就不行了?
多数是环境差异:出网代理、DNS、证书信任链、或者服务器上配的环境变量没生效。排查顺序是先确认网络能通,再确认密钥读到了,最后看请求有没有被中间网关改写或拦截,逐步排除比反复改代码快。