feat(flow): 添加AI代理平台支持并优化流式处理

- 在FlowActionExecutorService中集成LLMAiAgentPlatformService和LLMAiTtsService
- 实现AI_AGENT_PLATFORM类型的执行逻辑,支持代理平台查询功能
- 重构FlowHttpNodeHandler参数解析,支持JSON和表单数据格式
- 在FlowItemExecutor中清空起始节点输入定义
- 重写FlowNodeParamPreparer参数准备逻辑,支持嵌套参数处理
- 优化LlmChatService中的SSE流处理,实现句子级别异步处理
- 添加TTS语音播放功能,在流式响应中实时处理句子
- 修改评估服务调用方式,在FlowEndNodeHandler中注释掉原有逻辑
This commit is contained in:
lixiaolong 2026-04-15 11:50:01 +08:00
parent 722e8a6ba7
commit 37d800be69
6 changed files with 192 additions and 58 deletions

View File

@ -1,7 +1,9 @@
package com.cmvr.llm.util;
import com.cmvr.llm.service.LLMAiTtsService;
import com.fasterxml.jackson.databind.ObjectMapper;
import okhttp3.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.annotation.PreDestroy;
@ -10,9 +12,8 @@ import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
/**
@ -20,7 +21,8 @@ import java.util.concurrent.atomic.AtomicReference;
*/
@Service
public class LlmChatService {
@Autowired
private LLMAiTtsService llmAiTtsService;
private static final String DEFAULT_BASE_URL = "https://aiagentplatform.cmft.com/api/proxy/api/v1";
private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
private static final ObjectMapper objectMapper = new ObjectMapper();
@ -122,6 +124,9 @@ public class LlmChatService {
}
}
// 句子结束符号
static final String SENTENCE_END = "?!。?!\n";
/**
* 执行SSE请求 - 完全保持原executeSseRequest逻辑改为同步返回
*/
@ -137,18 +142,49 @@ public class LlmChatService {
.build();
StringBuilder fullContent = new StringBuilder();
CountDownLatch latch = new CountDownLatch(1);
CountDownLatch mainLatch = new CountDownLatch(1);
AtomicReference<Throwable> errorRef = new AtomicReference<>();
AtomicReference<String> finalMessageIdRef = new AtomicReference<>("");
// ====================== 核心控制 ======================
// 接收缓存永远不阻塞
StringBuilder receiveBuffer = new StringBuilder();
// 单线程串行执行保证上一句处理完才处理下一句
ExecutorService sentenceExecutor = Executors.newSingleThreadExecutor();
// 标记流是否已经结束
AtomicBoolean streamFinished = new AtomicBoolean(false);
final AtomicBoolean interrupted = new AtomicBoolean(false); // 中断标记
Call call = httpClient.newCall(request);
Runnable interruptTask = () -> {
interrupted.set(true);
call.cancel(); // 真正关闭SSE连接 关键
streamFinished.set(true);
sentenceExecutor.shutdownNow();
mainLatch.countDown();
};
// 使用异步调用但阻塞等待结果
call.enqueue(new Callback() {
/**
* 等待所有句子处理完再结束
*/
private void shutdownAndWait1() {
try {
sentenceExecutor.shutdown();
sentenceExecutor.awaitTermination(10, TimeUnit.MINUTES);
} catch (InterruptedException ignored) {}
mainLatch.countDown();
}
@Override
public void onFailure(Call call, IOException e) {
errorRef.set(e);
latch.countDown();
streamFinished.set(true);
shutdownAndWait1();
}
@Override
@ -156,7 +192,8 @@ public class LlmChatService {
if (!response.isSuccessful()) {
String errorBody = response.body() != null ? response.body().string() : "无错误信息";
errorRef.set(new IOException("SSE请求失败: " + response.code() + " - " + errorBody));
latch.countDown();
streamFinished.set(true);
shutdownAndWait1();
return;
}
@ -168,7 +205,7 @@ public class LlmChatService {
new InputStreamReader(response.body().byteStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
while (!interrupted.get() && (line = reader.readLine()) != null) {
if (line.isEmpty()) {
currentEvent = "";
continue;
@ -190,8 +227,9 @@ public class LlmChatService {
if ("[DONE]".equals(data)) {
fullContent.append(localContent);
finalMessageIdRef.set(finalMessageId);
latch.countDown();
return;
streamFinished.set(true);
processBufferIfNeed();
break;
}
try {
@ -208,6 +246,8 @@ public class LlmChatService {
String answer = extractString(jsonData, "answer", "content", "chunk", "text");
if (!answer.isEmpty()) {
localContent.append(answer);
receiveBuffer.append(answer);
processBufferIfNeed();
}
} else if ("message_start".equals(event)) {
System.out.println("消息开始ID: " + finalMessageId);
@ -216,7 +256,8 @@ public class LlmChatService {
} else if ("message_end".equals(event) || "end".equals(event)) {
fullContent.append(localContent);
finalMessageIdRef.set(finalMessageId);
latch.countDown();
streamFinished.set(true);
processBufferIfNeed();
return;
}
@ -229,22 +270,76 @@ public class LlmChatService {
// 流正常结束
fullContent.append(localContent);
finalMessageIdRef.set(finalMessageId);
latch.countDown();
} catch (IOException e) {
errorRef.set(e);
latch.countDown();
streamFinished.set(true);
} finally {
processBufferIfNeed();
shutdownAndWait1();
}
}
/**
* 核心提取完整句子 + 异步串行处理
*/
private void processBufferIfNeed() {
if (interrupted.get()) return;
if (!true) return;
sentenceExecutor.submit(() -> {
while (true) {
String buffer = receiveBuffer.toString();
// 寻找最后一个句子结束符
int lastSplit = -1;
for (int i = 0; i < buffer.length(); i++) {
if (SENTENCE_END.contains(String.valueOf(buffer.charAt(i)))) {
lastSplit = i;
}
}
// 有完整句子 流已结束
if (lastSplit >= 0 || streamFinished.get()) {
String sentence;
if (lastSplit >= 0) {
// 截取完整句子
sentence = buffer.substring(0, lastSplit + 1).trim();
// 保留剩余内容
String remain = buffer.substring(lastSplit + 1).trim();
receiveBuffer.setLength(0);
receiveBuffer.append(remain);
} else {
// 流结束直接处理剩余
sentence = buffer.trim();
receiveBuffer.setLength(0);
}
if (!sentence.isEmpty()) {
if (interrupted.get()) return;
yourAsyncMethod(sentence);
}
// 处理完继续循环看是否还有新句子
if (streamFinished.get() && receiveBuffer.length() == 0) {
break;
}
} else {
// 无完整句子退出
break;
}
}
});
}
});
// 等待流式传输完成
try {
boolean completed = latch.await(120, TimeUnit.SECONDS);
boolean completed = mainLatch.await(120, TimeUnit.SECONDS);
if (!completed) {
throw new IOException("请求超时");
}
} catch (InterruptedException e) {
interruptTask.run(); // 被中断时立即停止SSE
Thread.currentThread().interrupt();
throw new IOException("请求被中断", e);
}
@ -260,6 +355,15 @@ public class LlmChatService {
return fullContent.toString();
}
private void yourAsyncMethod(String sentence) {
try {
System.out.println("正在处理句子: " + sentence);
llmAiTtsService.play("http://127.0.0.1:8080/tts/play", sentence, "x4_yezi", "50", "80");
} catch (Exception e) {
throw new RuntimeException(e);
}
}
/**
* 完全保持原extractString方法
*/

View File

@ -47,7 +47,7 @@ public class FlowEndNodeHandler implements FlowNodeTypeHandler {
Thread.currentThread().setName("evaluation-thread-" + Thread.currentThread().getId());
try {
log.info("异步评估任务开始执行threadName={}, instId={}, itemId={}", Thread.currentThread().getName(), instId, itemId);
exAeEvaluationService.executeEvaluation(jsonObject);
// exAeEvaluationService.executeEvaluation(jsonObject);
log.info("异步评估任务执行结束threadName={}, instId={}, itemId={}", Thread.currentThread().getName(), instId, itemId);
} catch (Exception e) {
log.error("评估任务执行异常threadName={}, instId={}, itemId={}, 错误={}", Thread.currentThread().getName(), instId, itemId, e.getMessage(), e);

View File

@ -20,22 +20,31 @@ public class FlowHttpNodeHandler implements FlowNodeTypeHandler {
public TaskNodeExecuteResult handle(TaskNodeExecuteMessage message) {
try {
JSONObject inputParams = message.getInputParams();
JSONObject config = inputParams.getJSONObject("config");
JSONObject body = inputParams.getJSONObject("body");
JSONObject headers = inputParams.getJSONObject("headers");
// 1. 必填参数校验
String url = inputParams.getString("url");
String url = config.getString("url");
if (url == null || url.isEmpty()) {
return TaskNodeExecuteResult.failure("HTTP url is required");
}
String method = inputParams.getString("method");
String method = config.getString("method");
if (method == null) {
method = "POST";
}
int timeout = inputParams.getIntValue("timeout", 100000);
int timeout = config.getIntValue("timeout", 100000);
JSONObject headers = inputParams.getJSONObject("headers");
Object bodyCfg = inputParams.get("body");
Object bodyCfg = null;
if (!"json".equals(body.getString("bodyType"))) {
JSONObject formData = body.getJSONObject("formData");
formData.remove("_self_");
bodyCfg = formData;
} else {
bodyCfg = body.get("json");
}
// 2. 构造 HTTP 请求
HttpRequest request = HttpRequest.of(url)
.method(Method.valueOf(method.toUpperCase()))

View File

@ -50,7 +50,7 @@ public class FlowItemExecutor {
FlowGraph graph = FlowModelBuilder.buildExecutableGraph(item.getFlowData());
String startNodeId = graph.findStartNodeId();
List<FlowParamDef> startInputDefs = graph.getStartInputParamsDefs();
startInputDefs.clear();
// 执行 start 节点同步执行
NodeExecutor executor = node ->
executeNode(graph, node, startNodeId, startInputDefs, rootMessage, onFinished, new ArrayList<>());

View File

@ -1,6 +1,7 @@
package com.cmvr.test.flow.runtime.engine.support;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.collection.CollectionUtil;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
@ -30,45 +31,10 @@ public class FlowNodeParamPreparer {
FlowNodeWrapper node,
TaskNodeExecuteMessage rootMessage) {
JSONObject input = new JSONObject();
TaskContext ctx = taskInstHolder.getContext(rootMessage.getInstId());
// 遍历当前节点定义的参数
for (FlowParamDef param : node.getNodeParams()) {
if (param.getScope() != ParamScope.NODE) continue;
// 1 静态 input 参数
if ("input".equals(param.getType())) {
input.put(param.getName(), param.getInput());
}
// 2 引用参数quote
else if ("quote".equals(param.getType()) && CollUtil.isNotEmpty(param.getQuote())) {
String refNodeId = param.getQuote().get(0); // 被引用的上游节点 ID
List<String> path = param.getQuote().subList(2, param.getQuote().size()); // 取值路径
JSONObject source;
// 引用的参数是上游的输入还是输出
// 2.1 引用上游输入参数
if ("input".equalsIgnoreCase(param.getQuoteType())) {
source = ctx.getRunParams();
JSONObject def = toJson(graph.getNodeParamsDefs().get(refNodeId));
for (String key : def.keySet()) {
source.put(key, def.get(key));
}
} else if ("output".equalsIgnoreCase(param.getQuoteType())) {
// 2.2 引用上游的 output 参数运行时实际执行结果
// nodeId + iterations 作为 key避免覆盖
source = ctx.getNodeOutput(refNodeId, rootMessage.getIterations());
} else {
throw new GlobalException("参数应用类型出错quoteType:" + param.getQuoteType());
}
// 按路径取嵌套值例如 ["result", "image", "url"]
Object val = findNestedValue(source, path);
input.put(param.getName(), val);
}
}
JSONObject input = getInputParams(graph, node.getNodeParams(), rootMessage);
// 3 START 节点 runParams 覆盖默认参数
if (node.getNodeType() == NodeTypeEnum.START) {
@ -119,6 +85,55 @@ public class FlowNodeParamPreparer {
return input;
}
private JSONObject getInputParams(FlowGraph graph, List<FlowParamDef> paramDefList, TaskNodeExecuteMessage rootMessage) {
JSONObject input = new JSONObject();
TaskContext ctx = taskInstHolder.getContext(rootMessage.getInstId());
// 遍历当前节点定义的参数
for (FlowParamDef param : paramDefList) {
if (param.getScope() != ParamScope.NODE) continue;
// 1 静态 input 参数
if ("input".equals(param.getType())) {
input.put(param.getName(), param.getInput());
}
// 2 引用参数quote
else if ("quote".equals(param.getType()) && CollUtil.isNotEmpty(param.getQuote())) {
String refNodeId = param.getQuote().get(0); // 被引用的上游节点 ID
List<String> path = param.getQuote().subList(2, param.getQuote().size()); // 取值路径
JSONObject source;
// 引用的参数是上游的输入还是输出
// 2.1 引用上游输入参数
if ("input".equalsIgnoreCase(param.getQuoteType())) {
source = ctx.getRunParams();
JSONObject def = toJson(graph.getNodeParamsDefs().get(refNodeId));
for (String key : def.keySet()) {
source.put(key, def.get(key));
}
} else if ("output".equalsIgnoreCase(param.getQuoteType())) {
// 2.2 引用上游的 output 参数运行时实际执行结果
// nodeId + iterations 作为 key避免覆盖
source = ctx.getNodeOutput(refNodeId, rootMessage.getIterations());
} else {
throw new GlobalException("参数应用类型出错quoteType:" + param.getQuoteType());
}
// 按路径取嵌套值例如 ["result", "image", "url"]
Object val = findNestedValue(source, path);
input.put(param.getName(), val);
}
if (!CollectionUtil.isEmpty(param.getChildren())) {
JSONObject inputParams = getInputParams(graph, param.getChildren(), rootMessage);
// 添加 _self_ 参数用于在子参数中引用父参数
inputParams.put("_self_",input.get(param.getName()));
// 添加子参数
input.put(param.getName(), inputParams);
}
}
return input;
}
/**
* 将参数定义列表中的 input 值构建为 JSONObject

View File

@ -12,6 +12,8 @@ import com.cmvr.edge.client.service.EdgeCameraService;
import com.cmvr.edge.client.service.EdgeHlcService;
import com.cmvr.edge.client.service.EdgeMicrophoneService;
import com.cmvr.edge.client.service.EdgeSpeakerService;
import com.cmvr.llm.service.LLMAiAgentPlatformService;
import com.cmvr.llm.service.LLMAiTtsService;
import com.cmvr.test.enums.ActionEnum;
import com.cmvr.test.model.vo.FlowActionRequestVO;
import lombok.RequiredArgsConstructor;
@ -27,6 +29,8 @@ public class FlowActionExecutorService {
private final EdgeMicrophoneService edgeMicrophoneService;
private final EdgeSpeakerService edgeSpeakerService;
private final EdgeHlcService edgeHlcService;
private final LLMAiTtsService llmAiTtsService;
private final LLMAiAgentPlatformService llmAiAgentPlatformService;
public String actionExecute(FlowActionRequestVO req) {
@ -115,6 +119,8 @@ public class FlowActionExecutorService {
return "LLM 意图识别 OK";
case GENERATE_ADVANCED_AUDIO:
return "LLM 高级音频生成 OK";
case AI_AGENT_PLATFORM:
return llmAiAgentPlatformService.query(action.getAction(), req.getPayload().getString("text"), req.getPayload().getString("apiKey")).toString();
default:
throw new UnsupportedOperationException("未实现的 LLM Action: " + action);
}