1. 背景
上个月底前一周接收到一个冲刺需求,要求把当前部门给员工开放的 LLM API (已经部署的开源模型以及内部闭源模型) 提供对话体验能力,主要要求如下:
- 支持模型参数调整;
- 流式响应;
- 可以切换模型进行对话体验;
- 对话结束后显示模型性能参数(如首token生成时间、token生成速率等);
- 可以中断对话。
其实还有一个点就是基于现有的java jdk8版本进行开发。以上差不多就是需求描述的所有内容了,月底功能也是上线了,接下来就简单说说我是怎么做的,以及这个过程中遇到了哪些问题。
2. 方案
2.1. 需求理解
首先分析下根据需求我要提供哪些接口来满足以上功能:
- 提供模型参数元数据查询接口(用户可以根据前端渲染后的元数据,更改模型参数来调用对话);
- 提供sse方式的流式对话接口(该接口的出参、入参需要适配不同模型);
- 提供模型对话中断接口(根据流式接口返回的会话id来打断) 。
以上就是要提供的主要接口,主要的难点也就是对话接口,它要提供多种返回数据,分别如下:
- 在建立连接后先返回一个会话id,用户中断模型输出;
- 适配不同模型调用底层LLM api;
- 适配不同模型返回的数据,转换成统一的响应数据;
- 计算出性能参数。
2.2. 核心功能设计
2.2.1. 统一输入输出参数
这里主要关注对输入模型的识别,以及对输出内容的转换。为了实现这个功能,定义了一个适配器做模型参数的适配,具体如下:
- 服务启动时将所有适配器加载到bean中
- 具体调用的时候根据模型名称匹配使用模型
- 适配的模型请求参数组装
- 发起模型调用,接受底层模型流式响应,转换成统一封装内容
具体的类图如下,定义了一个StreamChatService的接口,该接口定义了两个主要方法,使用抽象类实现该接口,并封装主要的实现方法,提高代码复用;最后就是每个适配类单独继承抽象类做一些定制化参数的组装等功能。
2.2.2. 对话中断
对话中断主要关注两个点,首先在对话开始的时候要给前端返回一个会话id,其次当发起终止对话后要将底层的网络调用给关闭,实现真正的中断,而不是简单通过前端不再接收后端返回的假中断。
这个功能我主要借鉴了当前 kimi 和 deepseek 实现的模型对话中断,如下图所示,kimi 的对话终止是基于两个接口的配合实现的所以这里我也使用这个方式。
3. 代码示例
下面给出流式对话的部分代码
controller层:
private final ConcurrentMap<String, StreamContext> activeStreams = new ConcurrentHashMap<>();
@PostMapping("/chat/stream/cancel")
public ResponseEntity<String> cancelStream(@RequestParam String streamId) {
StreamContext context = activeStreams.get(streamId);
if (context != null) {
context.cancelToken.set(true);
return ResponseEntity.ok("Cancellation initiated for stream: " + streamId);
}
return ResponseEntity.status(404).body("Stream not found or already completed");
}
@PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamChat(@RequestBody ModelChatRequestVO chatRequest) {
SseEmitter emitter = new SseEmitter(5 * 60 * 1000L);
AtomicBoolean isCompleted = new AtomicBoolean(false);
AtomicBoolean cancelToken = new AtomicBoolean(false);
// 生成唯一流ID
String streamId = UUID.randomUUID().toString();
StreamContext context = new StreamContext(emitter, cancelToken, isCompleted);
activeStreams.put(streamId, context);
// 发送会话流ID给客户端
try {
emitter.send(SseEmitter.event()
.name("stream-init")
.data(Collections.singletonMap("streamId", streamId)));
} catch (IOException e) {
log.error("Failed to send stream ID", e);
}
// 设置清理回调
emitter.onCompletion(() -> {
isCompleted.set(true);
activeStreams.remove(streamId);
});
emitter.onTimeout(() -> {
isCompleted.set(true);
activeStreams.remove(streamId);
emitter.complete();
});
// ... emitter.send() ...
return emitter;
}
抽象类层:
@Override
public void streamChat(MaasChatRequest chatRequest,
Consumer<StreamResponse> callback,
AtomicBoolean cancelToken) throws IOException {
// ... [请求参数组装] ...
okHttpClient.newCall(request).enqueue(new Callback() {
@Override
public void onFailure(Call call, IOException e) {
if (!call.isCanceled()) {
callback.accept(StreamResponse.error("Error: " + e.getMessage()));
}
}
@Override
public void onResponse(Call call, Response response) throws IOException {
try (ResponseBody responseBody = response.body()) {
// ... [省略] ...
BufferedReader reader = new BufferedReader(responseBody.charStream());
String line;
while ((line = reader.readLine()) != null) {
// 优先检查中断信号
if (cancelToken.get()) {
call.cancel();
callback.accept(StreamResponse.interrupted("用户中断"));
break;
}
// ... [正常处理] ...
}
} catch (Exception e) {
// 处理中断异常
if (e instanceof IOException && "Canceled".equals(e.getMessage())) {
callback.accept(StreamResponse.interrupted("请求已取消"));
} else {
// ... [省略] ...
}
}
}
});
}
4. 小结
ok,以上就是我基于java开发的大模型对话功能的实践。有不对的地方希望可以有人指正,当然你如果有更好的想法也可以探讨下。哦对了,服务部署到测试环境后记得配置下ng代理,不然你会发现的流式在本地是ok的,上线就有问题了。