
VocabVerse背单词应用的AI板块开发
使用流式 SSE 模拟 Deepseek-R1风格的对话体验支持“静默分析”功能(分离主界面与分析逻辑)实现了用户“关键词 → 故事 → 四格漫画结构”的完整链路(目前实现了前部分)全组件响应式 UI,极大提升交互性与美观性。
基于 DeepSeek-R1 的流式对话生成与故事结构提取模块设计
我们继续我们的安卓开发
我们上次设计好了导航和主页,在此基础上我进一步集成,更加鲁棒丰富
今天终于轮到开发AI模块了
我们这个 AI 聊天接口(基于 DeepSeek 模型、结合 Compose 界面 + ViewModel 架构 + SSE 流式响应)实现的功能是:
根据选中的单词生成相关的故事,从而辅助我们记忆
这个功能十分重要,是有承上启下的作用(既是“背单词”功能的拓展,又是SD漫画生成功能的基础)
我们从整体目标、功能拆解、技术选型、架构演进、具体实现细节、以及关键探索过程等方面,展开详细阐述。
一、项目目标与核心需求
目标
构建一个能够进行流式对话生成,并具备“分析故事结构能力”的 AI 聊天接口系统。该系统以词汇为输入,生成一个生动、有逻辑的英文故事,支持进一步将故事解析为四格漫画结构。
功能需求
-
用户可选择多个英文单词作为故事主题
-
模型生成英文故事(350词以内)
ps由于SD的字数限制,我们将字数限定在350以内
-
用户可对生成的故事执行“结构分析”,输出符合
[4koma]
+[SCENE-x]
结构的描述(跟漫画生成相关) -
所有生成过程为流式响应,提升用户体验
-
支持“静默请求”用于后台处理,不影响主界面交互
二、技术方案与架构设计
架构概览
用户界面 (Jetpack Compose)
↓
ViewModel(状态管理 + 业务逻辑)
↓
DeepSeekService(SSE流式调用API)
↓
DeepSeek-R1 模型(远程部署)
技术选型说明
模块 | 技术 | 选型理由 |
---|---|---|
前端界面 | Jetpack Compose | 原生支持响应式 UI,方便状态驱动(这里之前文章介绍了,不多阐述) |
状态管理 | ViewModel + mutableStateOf |
存储关键的数据,避免重组冲突,保障 UI 响应流畅 |
通信协议 | OkHttp + SSE (EventSource ) |
支持 OpenAI-like 的 Chat Completion 流式响应 |
数据格式 | JSON + Prompt Template | 提高模型结构化输出的稳定性 |
模型后端 | DeepSeek-R1 | 性能强、支持本地部署、兼容 OpenAI API 结构 |
模块拆解设计思路
1. ViewModel 模块(DeepSeekViewModel
)
- 核心职责:
- 管理 UI 状态:响应流状态、历史对话记录等
- 协调请求过程(开始、完成、取消)
- 提供
sendPrompt
请求方法
- 设计思路:
- 使用
mutableStateOf
管理响应文本,Compose 会自动响应更新 - 使用
Job
管理协程,保证请求唯一性 - 区分
uiState
实现“主界面 + 背景分析”分离
- 使用
2. 流式响应服务模块(DeepSeekService
)
- 核心职责:
- 通过 OkHttp 构建 Chat Completion 的流式请求
- 使用 SSE 协议 (
EventSource
) 持续接收模型输出片段
- 设计思路:
- 用
callbackFlow
封装流式数据处理 - 对于每个 JSON chunk,提取 delta 内容后用
trySend
发送给上层收集器 - 用
StringBuilder
缓冲最终结果,便于onCompletion
收尾处理
- 用
3. UI 设计(DeepSeekChatScreen
)
- 模块结构:
- 单词选择区域:可支持多选关键词
- 输出区域:展示响应流及上下文
- 操作区域:控制查询、清除、分析按钮
- 设计亮点:
- 基于状态控制按钮的启用/禁用逻辑,避免重复请求
- 分段响应实时显示,配合缓冲区优化用户等待体验
- 可一键触发分析模块,将故事转为结构化 4koma 场景
三、探索过程与设计演进记录
探索起点
第一难:api接口调试
刚开始拿到的api时候,以为通过域名、api和参数直接post就能用了
于是同postman测试,发现根本行不通
于是我们可以找到one api的官网来寻找方法
可以看到,基础的格式为
OPENAI_API_KEY="sk-xxxxxx"
OPENAI_API_BASE="https://<HOST>:<PORT>/v1"
注意:这个v1一定要有
知道了这个,我们还不能直接用,还需要了解输入和接收的格式
我们可以进入DeepSeek-R1 API官网的手册学习
官网里面有个输入输出的规范:
根据官网的介绍我们可以看到,url的配置还需要加上/chat/completions
所以我们的格式就可以继续完善
OPENAI_API_KEY="Bearer sk-xxxxxx"
OPENAI_API_BASE="https://<HOST>:<PORT>/v1/chat/completions"
然后输入的格式就是个json,其中包含字段为
{
"model": "deepseek-chat",
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello!"}
],
"stream": false
}
由于我们用的是R1模型,可以把model换成Deepseek-R1
{
"model": "Deepseek-R1",
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello!"}
],
"stream": false
}
用这个格式进行测试
可以看到结果为:
我们可以发现
输出的结果中,只有message字段里的content字段的内容是我们需要的
包含了思考和最终答案,正是我们需要的。
那么我们就利用这些信息进行kotlin代码书写
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okhttp3.sse.EventSource
import okhttp3.sse.EventSourceListener
import okhttp3.sse.EventSources
import org.json.JSONArray
import org.json.JSONObject
import java.util.concurrent.TimeUnit
class DeepSeekService(
private val apiBase: String = "XXXXXX", //为了安全,进行X代替
private val apiKey: String = "XXXXXX", //为了安全,进行X代替
private val model: String = "DeepSeek-R1"
) {
private val client = OkHttpClient.Builder()
.connectTimeout(60, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.build()
fun streamResponse(prompt: String): Flow<String> = callbackFlow {
val url = "$apiBase/chat/completions"
val payload = JSONObject().apply {
put("model", model)
put("messages", JSONArray().apply {
put(JSONObject().apply {
put("role", "user")
put("content", prompt)
})
})
put("temperature", 0.7)
put("max_tokens", 1024)
put("stream", true)
}
val request = withContext(Dispatchers.IO) {
Request.Builder()
.url(url)
.header("Authorization", "Bearer $apiKey")
.header("Content-Type", "application/json")
.post(payload.toString().toRequestBody())
.build()
}
val eventSourceFactory = EventSources.createFactory(client)
val finalResponse = StringBuilder() // 存储最终回复
val eventSourceListener = object : EventSourceListener() {
override fun onEvent(
eventSource: EventSource,
id: String?,
type: String?,
data: String
) {
val trimmedData = data.trim()
if (trimmedData == "[DONE]") {
// 请求结束时打印最终结果
Log.d("DeepSeekDebug", "请求完成,最终回复: $finalResponse")
close()
return
}
try {
val jsonObject = JSONObject(trimmedData)
val choices = jsonObject.getJSONArray("choices")
if (choices.length() > 0) {
val delta = choices.getJSONObject(0).getJSONObject("delta")
if (delta.has("content")) {
val content = delta.getString("content")
trySend(content) // 发送部分内容
}
}
} catch (e: Exception) {
trySend("\n[解析错误] ${e.message}")
}
}
override fun onFailure(
eventSource: EventSource,
t: Throwable?,
response: Response?
) {
trySend("\n[API请求错误] ${t?.message}")
close(t)
}
override fun onClosed(eventSource: EventSource) {
close()
}
}
val eventSource = eventSourceFactory.newEventSource(request, eventSourceListener)
awaitClose {
eventSource.cancel()
}
}
}
以上代码比较好理解,
这里用到的是OkHttpClient进行网络通信
核心的点就是将stream变成了true
并且用了SSE的 Flow 数据
trySend(content)
我会在接下来的优化部分将
第一次优化
-
引入流式响应(Streaming)
- 解决了响应慢的问题
- 但首次使用时,对 OkHttp SSE 支持不熟悉,尝试 WebSocket、HTTP1.1 等失败方案
- 最终选择 OkHttp + EventSource(官方推荐)
用了 OkHttp +
EventSource
实现对 AI 模型的 SSE 监听,这本质上就是模拟 OpenAI 的 Chat Completion Streaming。关键体现:
val request = Request.Builder() .url(apiUrl) .header("Authorization", "Bearer $apiKey") .post(body) .build() val eventSource = EventSources.createFactory(client) .newEventSource(request, object : EventSourceListener() { override fun onEvent(...) { // 解析 delta 内容,一片一片接收 } })
-
EventSource
接口:这是 Server-Sent Events (SSE) 协议的核心 -
onEvent()
回调:模型返回的内容不是一次性返回,而是一个 JSON 数据块一个数据块地返回 -
每次接收到一片 delta,就会:
val content = delta["content"].asString stringBuilder.append(content) trySend(content)
实现了边接收边展示,这是“流式响应”的具体体现
第二次优化:trySend
trySend(content)
是什么?
这是 callbackFlow
提供的一个函数,用于 将数据发送到 Flow 的下游(比如 ViewModel 或 UI)。
Flow 是怎么运作的?
定义了一个 Flow<String>
,别人可以通过 collect
来收集它:
val flow = deepSeekService.streamResponse("你好")
flow.collect { content ->
println(content) // 这里就能实时接收到 content
}
而 callbackFlow
是一种手动推送数据的 Flow 构造器。得在里面明确调用:
trySend(content)
来“发射”数据。
那为什么是 trySend()
而不是 send()
?
send(content)
是挂起函数,会挂起当前协程直到数据被消费。trySend(content)
是 非挂起函数,立即尝试发送数据,如果不能发送就返回失败结果(不是抛异常)。
它的优点:
- 更安全:不会因为 UI 不收数据就挂起卡住。
- 更适合在非协程线程里用(比如 SSE 回调)。
举个直观例子:
假设模型在不停返回:
data: {"choices":[{"delta":{"content":"你"}}]}
data: {"choices":[{"delta":{"content":"好"}}]}
data: {"choices":[{"delta":{"content":"呀"}}]}
每次解析出 "你"
, "好"
, "呀"
,就:
trySend("你")
trySend("好")
trySend("呀")
Flow 的订阅者就能一段一段地收到并显示出来,实现“实时打字效果”。
如果不调用 trySend
会怎样?
那 Flow 就永远不会向下游发出任何数据。虽然连接了 SSE,也收到了内容,但订阅者(比如 UI)一无所知,无法显示内容。
小总结:
方法 | 功能 |
---|---|
trySend(content) |
非挂起地把数据发给 Flow 的订阅者 |
在哪用 | 在异步回调或事件中(比如 SSE、WebSocket)推送内容 |
替代 | 如果在协程里,也可以用 send(content) |
模块分离
- 原来所有逻辑都在 UI 层,难以维护
- 引入 ViewModel,封装状态与业务逻辑,实现了 UI 与逻辑分离
class DeepSeekViewModel : ViewModel() {
private val deepSeekService = DeepSeekService()
private val TAG = "DeepSeekDebug00"
var uiState by mutableStateOf(DeepSeekUiState())
private set
var hiddenUiState by mutableStateOf(DeepSeekUiState())
private set
private var currentJob: Job? = null
fun sendPrompt(prompt: String) {
currentJob?.cancel()
currentJob = viewModelScope.launch {
val buffer = StringBuilder()
deepSeekService.streamResponse(prompt)
.onStart {
Log.d(TAG, "开始请求,用户输入: $prompt")
uiState = uiState.copy(
isLoading = true,
currentResponse = "",
fullResponse = uiState.fullResponse + "\n\n\n用户: $prompt"
)
}
.onCompletion { cause ->
val finalResponse = buffer.toString()
val newFullResponse = if (finalResponse.isNotBlank()) {
uiState.fullResponse + "\n\n\n助手: $finalResponse"
} else {
uiState.fullResponse + "\n\n\n助手: (无响应)"
}
uiState = uiState.copy(
isLoading = false,
currentResponse = "",
fullResponse = newFullResponse
)
if (cause == null) {
Log.d(TAG, "请求完成,最终回复111: $buffer")
} else {
Log.e(TAG, "请求失败: ${cause.message}")
}
}
.collect { chunk ->
buffer.append(chunk)
}
}
}
fun clearConversation() {
currentJob?.cancel()
uiState = DeepSeekUiState()
hiddenUiState = DeepSeekUiState()
}
}
data class DeepSeekUiState(
val isLoading: Boolean = false,
val currentResponse: String = "",
val fullResponse: String = ""
)
这个的sendPrompt就是核心
我们只需要在ui层的界面进行调用这个函数就行
ui层界面设计的内层,将单词传入sendPrompt()
即可
状态持久化探索
- 为了在 Compose 界面下状态不被重组清空,使用
mutableStateOf
搭配remember
、viewModelScope
- 避免了状态丢失、按钮逻辑重复等问题
故事结构化探索(与漫画有关)
- 初期模型生成的英文故事冗长、无结构
- 通过 prompt tuning(加入
[4koma]
、[SCENE-x]
等标签)约束模型输出格式 - 设计
extract4komaScenes()
函数提取结构,便于后续图像生成
该功能在下期会详细讲解
四、总结与亮点
创新点
- 使用流式 SSE 模拟 Deepseek-R1风格的对话体验
- 支持“静默分析”功能(分离主界面与分析逻辑)
- 实现了用户“关键词 → 故事 → 四格漫画结构”的完整链路(目前实现了前部分)
- 全组件响应式 UI,极大提升交互性与美观性
可扩展性
- 支持模型切换(只需更换 API 地址或 Key)
- 可对接图像生成接口,实现
[SCENE-x]
→ AI图像生成 的闭环
界面展示:
(ps:实际交付的时候应当把输入的内容删除,或者不显示)
更多推荐
所有评论(0)