package com.yunhai.agent.llm; import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONArray; import com.alibaba.fastjson.JSONObject; import com.yunhai.agent.config.AgentSettings; import com.yunhai.agent.llm.dto.ChatMessage; import com.yunhai.agent.llm.dto.ChatResult; import com.yunhai.agent.llm.dto.ToolCall; import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.utils.exception.ErrorCode; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; import java.util.ArrayList; import java.util.List; /** * 大模型客户端(OpenAI 兼容协议):默认对接 DeepSeek 官方 * {@code https://api.deepseek.com/chat/completions},模型 {@code deepseek-flash}。 *

模型标识以官方实时清单为准({@code GET https://api.deepseek.com/models})。 * *

协议要点: *

* *

换供应商只要改 {@code agent.llm.host}(豆包方舟 / 千问兼容模式 / 自有网关都遵循该协议)。 */ @Slf4j @Component public class DeepSeekClient implements LlmClient { private static final String TOOL_CHOICE_AUTO = "auto"; private final RestTemplate restTemplate; private final AgentSettings settings; public DeepSeekClient(@Qualifier("yhRestTemplate") RestTemplate restTemplate, AgentSettings settings) { this.restTemplate = restTemplate; this.settings = settings; } @Override public ChatResult chat(List messages, JSONArray tools) { AgentSettings.Llm llm = settings.getLlm(); if (StringUtils.isBlank(llm.getApiKey())) { throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "未配置大模型 api-key(agent.llm.api-key)"); } JSONObject body = new JSONObject(); body.put("model", llm.getModel()); body.put("max_tokens", llm.getMaxTokens()); JSONArray msgArr = new JSONArray(); for (ChatMessage m : messages) { msgArr.add(m.toJson()); } body.put("messages", msgArr); // 现役 V4 系列(deepseek-flash / deepseek-v4-pro)temperature 与 tools 都支持。 // 保留 isReasoner 判断只为兼容历史上"reasoner 不支持二者"的模型名,正常情况下恒为 false。 if (!isReasoner(llm.getModel())) { body.put("temperature", llm.getTemperature()); if (tools != null && !tools.isEmpty()) { body.put("tools", tools); body.put("tool_choice", TOOL_CHOICE_AUTO); } } HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.set("Authorization", "Bearer " + llm.getApiKey()); headers.set("Accept", "application/json"); String url = StringUtils.isNotBlank(llm.getHost()) ? llm.getHost() : llm.getFallbackHost(); String requestBody = JSON.toJSONString(body); long start = System.currentTimeMillis(); ResponseEntity resp; try { resp = restTemplate.postForEntity(url, new HttpEntity<>(requestBody, headers), String.class); } catch (Exception e) { log.error("调用大模型失败 url={} model={} err={}", url, llm.getModel(), e.toString()); throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, humanize(e, url)); } long cost = System.currentTimeMillis() - start; String raw = resp.getBody(); if (StringUtils.isBlank(raw)) { throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "大模型返回为空"); } JSONObject json; try { json = JSON.parseObject(raw); } catch (Exception e) { log.error("大模型返回非 JSON:{}", StringUtils.abbreviate(raw, 500)); throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "大模型返回格式异常"); } if (json.containsKey("error")) { JSONObject err = json.getJSONObject("error"); String msg = err == null ? null : err.getString("message"); String code = err == null ? null : err.getString("code"); log.error("大模型返回错误:code={} msg={}", code, msg); throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, humanizeApiError(msg)); } ChatResult result = new ChatResult(); result.setRaw(raw); JSONArray choices = json.getJSONArray("choices"); if (choices == null || choices.isEmpty()) { throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "大模型未返回任何选择"); } JSONObject message = choices.getJSONObject(0).getJSONObject("message"); if (message != null) { // 部分模型把 content 放在嵌套结构里(兼容历史网关),这里做一次宽容取值 Object contentObj = message.get("content"); if (contentObj instanceof String) { result.setContent((String) contentObj); } else if (contentObj != null) { result.setContent(JSON.toJSONString(contentObj)); } JSONArray toolCalls = message.getJSONArray("tool_calls"); if (toolCalls != null) { List list = new ArrayList<>(); for (int i = 0; i < toolCalls.size(); i++) { JSONObject tc = toolCalls.getJSONObject(i); ToolCall call = new ToolCall(); call.setId(tc.getString("id")); call.setType(tc.getString("type")); JSONObject fn = tc.getJSONObject("function"); if (fn != null) { ToolCall.Function f = new ToolCall.Function(); f.setName(fn.getString("name")); f.setArguments(fn.getString("arguments")); call.setFunction(f); } list.add(call); } result.setToolCalls(list); } } JSONObject usage = json.getJSONObject("usage"); if (usage != null) { result.setPromptTokens(usage.getInteger("prompt_tokens")); result.setCompletionTokens(usage.getInteger("completion_tokens")); } log.info("大模型调用完成 cost={}ms tools={} tokens={}/{}", cost, result.getToolCalls().size(), result.getPromptTokens(), result.getCompletionTokens()); return result; } /** * 把底层 HTTP 异常翻译成使用者能看懂的话。 * *

典型场景:密钥无效时服务端回 401,而请求体是流式发送的,JDK 的 {@code HttpURLConnection} * 会抛出 {@code HttpRetryException: cannot retry due to server authentication, in streaming mode} * —— 这句话直接抛给前端毫无信息量,需要还原成「密钥无效」。 */ private static String humanize(Throwable e, String url) { Integer status = findStatus(e); if (status != null && (status == 401 || status == 403)) { return "大模型鉴权失败(HTTP " + status + "):api-key 无效、已过期或无权限," + "请检查 agent.llm.api-key(或环境变量 DEEPSEEK_API_KEY)"; } if (status != null && status == 402) { return "大模型账户余额不足(HTTP 402):请前往大模型平台充值后重试"; } if (status != null && status == 429) { return "大模型请求过于频繁或账户余额不足(HTTP 429),请稍后重试或检查账户额度"; } if (status != null && status >= 500) { return "大模型服务端异常(HTTP " + status + "),请稍后重试"; } String m = e.getMessage() == null ? e.toString() : e.getMessage(); if (m.contains("cannot retry due to server authentication")) { return "大模型鉴权失败:api-key 无效或未配置,请检查 agent.llm.api-key"; } if (m.contains("UnknownHost")) { return "无法解析大模型地址 " + url + ",请检查网络或 agent.llm.host 配置"; } if (m.contains("onnection refused") || m.contains("connect timed out")) { return "无法连接大模型地址 " + url + ",请检查网络与代理设置"; } if (m.contains("imed out") || m.contains("imeout")) { return "调用大模型超时,请稍后重试(可调大 yh.http.read-timeout-ms)"; } return "大模型服务不可用:" + m; } /** * 把大模型返回体里的 {@code error.message} 翻成人话。 * *

该分支拿不到 HTTP 状态码(平台 RestTemplate 对 4xx 不回抛),只能按文案特征识别。 * 典型:余额为 0 时 DeepSeek 回 {@code Insufficient Balance}(对应 HTTP 402), * 原样抛给使用者等于没说清「要充值」。 */ private static String humanizeApiError(String msg) { String m = msg == null ? "" : msg; String lower = m.toLowerCase(); if (lower.contains("insufficient balance") || lower.contains("insufficient_quota") || lower.contains("exceeded your current quota") || lower.contains("payment required")) { return "大模型账户余额不足(" + m + "):请前往大模型平台充值后再试"; } if (lower.contains("authentication fails") || lower.contains("invalid api key") || lower.contains("invalid_api_key") || lower.contains("unauthorized")) { return "大模型鉴权失败(" + m + "):api-key 无效或已过期,请检查 agent.llm.api-key"; } if (lower.contains("rate limit") || lower.contains("too many requests")) { return "大模型请求过于频繁(" + m + "),请稍后重试"; } if (lower.contains("model not exist") || lower.contains("model_not_found") || lower.contains("invalid model") || lower.contains("does not exist")) { return "大模型模型标识无效(" + m + "):请用官方实时清单 GET https://api.deepseek.com/models " + "核对 agent.llm.model(当前应为 deepseek-flash 或 deepseek-v4-pro)"; } return "大模型错误:" + m; } /** 从异常链里挖出 HTTP 状态码(HttpRetryException / HttpStatusCodeException 两种常见载体) */ private static Integer findStatus(Throwable e) { for (Throwable t = e; t != null && t != t.getCause(); t = t.getCause()) { if (t instanceof java.net.HttpRetryException) { int code = ((java.net.HttpRetryException) t).responseCode(); if (code > 0) { return code; } } else if (t instanceof org.springframework.web.client.HttpStatusCodeException) { return ((org.springframework.web.client.HttpStatusCodeException) t).getRawStatusCode(); } } return null; } private boolean isReasoner(String model) { return model != null && model.toLowerCase().contains("reasoner"); } }