WUJS

I'm here

10 Mar 2025

随手记:大模型网页对话管理

因为想自己做一个大模型聊天的套壳站,要确定如何管理对话,先分析一下市面上现有的东西。

Deepseek

通过间断询问version.txtstatus.json来获取版本信息和标语信息。

还有一些火山引擎的ab测试等东西。

通过/api/v0/chat/create_pow_challenge创建pow挑战,对类似{"target_path":"/api/v0/file/upload_file"}{"target_path":"/api/v0/chat/completion"}比较消耗资源的操作都分别创建相应挑战。通过WASM计算后,在下次completion时带上x-ds-pow-response头给服务器验证。

具体completion逻辑:在completion请求之前,首先会获取新对话的id和seq_id。

历史对话列表传的参数是before_seq_idcount获取对话列表。这个seq_id看起来是全局的,每个对话都占了不同数量的seq_id

每条聊天记录都对应一组parent_idmessage_id用来组织聊天记录的结构(为了应对修改造成的同个位置多个记录这种操作,组织成树形结构)。

点击一条chat记录发送history_messages,同时携带参数cache_version,如果cache_version比新的小,则返回全量的聊天记录,如果相等则只返回含id和标题等元信息。

completion请求是SSE,重启后有请求resume_sse(把之前已生成部分原样传)。

Qwen

open webui改的。但加入了一些阿里云日志记录的东西。

感觉没有deepseek简洁,deepseek的sse中明显尽可能的减少了重复字符,在sse的时候只不断的发送新的token,而qwen这里每次sse都要发一个有很多字段的json。

不支持断点续传,就很简陋。考虑到这个可能是面向外国人的,估计通义千问会好点?但是UI太难看了,就不去分析了。

豆包

豆包的实现比较复杂,用户发送的消息在浏览器本地的 IndexDB 会存一份。当用户开启新对话提问后,由于这时新对话还没发送到后端,前端会给这个对话和消息生成一个本地的 Local ID。带着 Local ID 将请求发给后端。然后接受到后端给的ID后改用后端的ID。

由于豆包会把消息在本地存一份,因此在页面刷新后,它是知道上次 SSE 断在哪里的。观察豆包的 SSE 返回消息,它的 JSON 中有一个自增的 event_id 游标字段,断点续传时会带上这个 event_id,SSE 接口就只会返回在这之后的消息。不太明白这个有什么意义。

起初以为只是为了节省一点传输流量,但仔细想想,它的核心作用应该是为了保证断点续传的幂等性。前端通过携带event_id明确告知服务端已收到的进度,服务端就能精确地从断点处续传,避免了网络波动或重试导致的消息错乱或重复。

总结一个比较合适的方案

deepseek的实现是比较简洁的。我大概给出一个简单的实现。

难点:断点续传

两个结构:Chat和Message。 (顺便提一句,ID的选型有个坑要注意。如果后端用Snowflake这类64位整型,直接传给前端JavaScript会因为Number类型的精度限制而出问题。ID得到前端后可能就变了。稳妥的做法是后端传ID时统一转成字符串。)

新对话,通过create接口获取一个新chat id。

对话通过SSE传送。

结束后,如果这是第一次对话,总结成标题。

到底是应该每个chat算成一个task,还是每个message。 会有同时打开多个chat但是位于不同message的情况吗?可以但没必要,同时也要防止滥用。就应该以一个chat为一个task单位。

断点续传应该这么理解,message生成的时候,同时打开多个窗口,他们都应该能正确收到ongoing的生成。这就要求这其中每个窗口的http连接,都属于同一个task。

要解决这个问题,核心思路必须是把后端的LLM生成任务和前端的HTTP连接生命周期解耦。

一个可行的实现是:

  1. 后端收到生成请求后,不阻塞在当前HTTP连接上,而是启动一个独立的后台任务(如一个goroutine)来执行与大模型的完整交互。
  2. 这个后台任务将生成的内容,逐条发送到一个持久化的消息队列中,例如Redis Stream。Stream的Key可以与message_id关联。
  3. 前端通过SSE接口连接时,不再是直接等待LLM的实时响应,而是去消费对应Redis Stream中的消息。无论是首次连接、刷新页面还是新开窗口,都从头(或从上次的游标)开始读取Stream,从而保证了多端内容同步。
  4. 为了让前端知道某个对话当前是否正在生成中,可以在后台任务启动时,在Redis中设置一个状态标记(如 chat:status:<chatID> running,并设置一个兜底的过期时间)。前端加载对话时,检查此标记即可。任务结束后,删除此标记。

这个方案实际上就实现了一个类似豆包event_id的机制,只不过游标由Redis Stream的消息ID来管理,在服务端实现,对前端透明。