Files
yunhai-agent-service/src/main/java/com/yunhai/agent/llm/DeepSeekClient.java
T
lijing 8303b857e9
build / build-push (push) Successful in 40s
feat: 提交校园智能助手后端工程源码(Spring Boot 2.7.18 + 平台 starter + JPA + Nacos)
- com.yunhai.agent,端口 8093,context-path /agent,制品 target/agent.jar;与 NoticeService 同构
- CAS 单点登录 + 三层角色权限(学生/教师/管理员/访客)+ 会话与工具调用审计落库
- DeepSeek 大模型编排 + 7 个业务模块工具(请假/公告/宿舍/活动/作业/资源库/教师发展)
- 浏览器代执行协议(服务端挂起 → 前端用登录态代调 → toolResult 续跑)+ 调用失败深链兜底
- 登出修复:服务端真正 session.invalidate() + 前端两步登出 + logout-guard
- 保留 projman 注入的 .gitea/workflows/build.yml 与 docker/Dockerfile;docker/application-prod.yml、run-app.sh 随工程入库
2026-10-12 11:18:29 +08:00

257 lines
12 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}。
* <p>模型标识以官方实时清单为准({@code GET https://api.deepseek.com/models})。
*
* <p>协议要点:
* <ul>
* <li>请求体 {@code {model, messages, tools, tool_choice, max_tokens, temperature}}</li>
* <li>响应 {@code choices[0].message.content} 为正文,
* {@code choices[0].message.tool_calls[]} 为工具调用请求</li>
* <li>模型要求调用工具时 {@code finish_reason == "tool_calls"},此时 content 通常为空</li>
* </ul>
*
* <p>换供应商只要改 {@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<ChatMessage> 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<String> 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<ToolCall> 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 异常翻译成使用者能看懂的话。
*
* <p>典型场景:密钥无效时服务端回 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} 翻成人话。
*
* <p>该分支拿不到 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");
}
}