1. 从桌面到口袋:为什么要把 DeepSeek Harness 塞进手机
DeepSeek Harness 这套东西,最早是在桌面端跑起来的。它的定位很明确——给本地大模型提供一个统一的调度外壳,把模型加载、会话管理、工具调用、流式输出这些脏活累活全包了。你在电脑上敲一行命令,它就能把本地模型拉起来,通过 WebSocket 把推理结果一段段吐给前端。用起来确实爽,但问题也来了:模型跑在台式机上,人却不可能一直坐在台式机前。吃饭的时候想让它继续跑个长任务,通勤路上想瞄一眼输出进度,躺床上突然有个想法想接着聊——这些场景都指向同一个需求:把 Harness 的客户端装进口袋。
这个项目的核心思路,就是用腾讯开源的Kuikly框架,写一个跨端的 Harness 移动客户端。Kuikly 是腾讯基于 Kotlin 打造的一套跨平台 UI 方案,一套代码能同时跑在 Android、iOS、鸿蒙甚至 Web 上。选它而不是 Flutter 或 React Native,理由很实在:Harness 本身的服务端和工具链就是 Kotlin/JVM 生态,用 Kuikly 意味着客户端和服务端能共享同一套 Kotlin 数据模型、同一套序列化逻辑,连 WebSocket 的消息协议都能直接复用,省掉大量跨语言对齐的心智负担。
说白了,这个项目解决的是**"人在动、模型在跑"的错位问题**。它适合三类人参考:一是已经在桌面端用 Harness 管本地模型的开发者,想给自己加个移动端遥控器;二是想学 Kuikly 跨端开发、又不想写玩具 Demo 的 Kotlin 程序员;三是任何对 WebSocket 长连接、Host 协议设计、移动端流式渲染感兴趣的人。下面我把整个拆解过程、踩过的坑、以及能直接抄的配置,一条条摊开讲。
2. 整体架构设计与技术选型拆解
2.1 为什么是 Kuikly 而不是其他跨端方案
跨端框架的选择,本质上是在"性能、生态、开发效率"这个三角里找平衡点。我最初也考虑过 Flutter,Dart 写 UI 确实快,但它和 Kotlin 服务端之间隔着一层 FFI 或者 HTTP 桥接,数据模型要写两遍,改一个字段两边都得动。React Native 更不用说,JS 和 Kotlin 的类型系统对不上,WebSocket 消息的序列化反序列化全靠手写,维护成本高。
Kuikly 的优势在于语言同构。Harness 服务端的会话状态、工具调用参数、流式 chunk 结构,全都是 Kotlin data class。用 Kuikly 写客户端,这些类可以直接放进 shared 模块,客户端和服务端引用同一份定义。改协议的时候,编译器会直接告诉你哪里没对齐,而不是等到运行时才报序列化错误。这个收益在项目迭代中期特别明显——我改过三次消息格式,每次都是编译期就发现问题,没出过一次线上崩溃。
另一个关键点是 Kuikly 的渲染机制。它不像 Flutter 那样自绘所有控件,而是把声明式的 UI 描述映射到各平台原生控件上。这意味着在 Android 上,你的列表滚动、文本选择、输入框行为,都是系统原生体验,不会出现 Flutter 那种"滚动惯性跟系统不一样"的割裂感。对于 Harness 这种需要频繁滚动查看长输出的场景,原生滚动的顺滑度是刚需。
2.2 Host 协议:客户端与服务端的契约
整个项目最核心的设计,是客户端和服务端之间的Host 协议。你可以把它理解成一份"通信合同":客户端能发哪些指令,服务端会回哪些事件,每条消息长什么样,全在这份协议里定死。
我采用的是双向 WebSocket + JSON 消息体的方案。为什么不用 gRPC 或者纯 HTTP 轮询?HTTP 轮询的延迟太高,Harness 的流式输出是逐 token 吐的,轮询根本追不上;gRPC 在移动端的支持虽然有了,但调试起来麻烦,抓包不如 WebSocket 直观。WebSocket 建一条长连接,服务端有输出就推,客户端有指令就发,双向对等,最贴合 Harness 的交互模型。
协议的消息结构我设计成统一信封格式:
@Serializable data class HostMessage( val type: String, // 消息类型:command / event / heartbeat val id: String, // 消息唯一 ID,用于请求响应配对 val timestamp: Long, // 毫秒时间戳 val payload: JsonElement // 具体内容,按 type 反序列化 )这个信封设计的好处是可扩展。以后要加新消息类型,只要在 payload 里塞新结构,信封本身不用动。id 字段用于请求响应配对——客户端发一条 command,服务端处理完回一条 event,通过 id 关联,避免异步场景下的响应错乱。
2.3 连接层与 UI 层的职责划分
架构上我做了清晰的分层,避免 UI 代码里到处散落 WebSocket 调用:
- 连接层(HarnessClient):负责 WebSocket 的建立、重连、心跳、消息收发。对外暴露
connect()、send()、Flow<HostMessage>三个接口。 - 状态层(SessionStore):订阅连接层的消息流,把原始消息转换成 UI 需要的状态(会话列表、当前输出、工具调用状态等)。
- UI 层(Kuikly 页面):只读状态层的数据,渲染界面,用户操作通过状态层的方法转发给连接层。
这样分层之后,UI 层完全不知道 WebSocket 的存在,测试的时候可以 mock 一个假的状态层,不用真连服务端。连接层的重连逻辑也能独立测试,不用起 UI。
3. 核心细节解析与实操要点
3.1 WebSocket 长连接的稳定性处理
移动端的网络环境比桌面恶劣得多——切 WiFi、进电梯、锁屏、后台被杀,每一种都会断连。如果只是简单地在onFailure里重连,用户体验会很差:断连期间的消息全丢,重连后状态对不上。
我的做法是带状态恢复的重连机制。客户端本地维护一个lastReceivedSeq(最后收到的消息序号),重连成功后第一件事就是发一条resume指令,带上这个序号。服务端收到后,把序号之后的所有消息补发过来。这样即使断了十几秒,重连后也能把中间的输出补齐,用户感知不到中断。
心跳也不能少。我设置的是客户端每 20 秒发一次 ping,服务端 25 秒内没收到就认为连接失效。为什么是 20 秒?因为移动网络下 NAT 超时通常在 30 秒到 5 分钟之间,20 秒的心跳能保证绝大多数情况下连接不被中间设备回收。心跳消息走的是轻量信封,payload 为空,不占带宽。
注意:心跳定时器一定要在连接建立成功后再启动,断连时立刻取消。我踩过一次坑,定时器没取消,重连后起了两个心跳,服务端收到重复 ping 直接判定异常断开了。
3.2 流式输出的增量渲染
Harness 的输出是逐 token 流式返回的,如果每收到一个 token 就刷新一次 UI,在低端机上会卡成幻灯片。我的处理是批量合并 + 帧率对齐。
具体做法:连接层收到 token 后不直接推给 UI,而是先塞进一个缓冲区。状态层用一个 16ms 的定时器(约 60fps)去消费缓冲区,把这段时间内积累的所有 token 拼成一个字符串,一次性更新 UI 状态。这样无论服务端吐得多快,UI 最多每秒刷新 60 次,且每次都是批量更新。
private val buffer = StringBuilder() private var flushJob: Job? = null fun onToken(token: String) { buffer.append(token) if (flushJob == null) { flushJob = scope.launch { delay(16) val text = buffer.toString() buffer.clear() _outputState.value = text flushJob = null } } }这个 16ms 不是随便定的。人眼对流畅的感知阈值大约在 60fps,也就是 16.6ms 一帧。低于这个间隔的刷新,人眼分辨不出来,纯属浪费性能。实测下来,这个策略让低端机上的滚动帧率从 20 多提升到了 55 以上。
3.3 Kotlin 数据模型的序列化陷阱
用 Kotlin 写跨端数据模型,序列化是最容易翻车的地方。我用的 kotlinx.serialization,有几个坑必须提前说:
第一,默认值字段在反序列化时的行为。如果服务端发的 JSON 里缺了某个有默认值的字段,kotlinx.serialization 会用默认值填充,这通常没问题。但如果服务端显式发了null,而你的字段是非空类型,就会直接抛异常。我的做法是所有可能为空的字段都声明成可空类型,宁可多写几个?.,也不要运行时崩溃。
第二,枚举的兼容性。Harness 的消息类型以后可能会增加,如果客户端用 enum 接收,遇到未知类型会反序列化失败。我改成了用 String 接收 type 字段,在业务层用 when 判断,未知类型走默认分支忽略掉。这样服务端加新消息类型时,老客户端不会崩,只是不处理而已。
第三,时间戳的精度。Kotlin 的 Long 在 JS 平台(Kuikly 支持 Web 端)只有 53 位精度,毫秒时间戳勉强够用,但如果以后要精确到微秒就会溢出。我统一用 Long 存毫秒,并且在协议文档里写死这个约定。
3.4 本地配置的持久化
客户端需要存一些本地配置:服务端地址、上次连接的会话 ID、用户偏好等。Android 上可以用 SharedPreferences,但 Kuikly 要跨端,得用统一的方案。我选的是multiplatform-settings这个库,它在 Android 上底层走 SharedPreferences,在 iOS 上走 NSUserDefaults,在 Web 上走 localStorage,对外暴露统一的 key-value 接口。
存的东西不多,就几个字段,但有个细节要注意:服务端地址这种可能带端口的字符串,存之前要 trim 掉首尾空格。我遇到过用户复制地址时带了个尾随空格,连接一直失败,排查了半天才发现是空格的问题。现在存之前统一trim(),省心。
4. 实操过程与核心环节实现
4.1 环境搭建与 Kuikly 工程初始化
第一步是把 Kuikly 的开发环境搭起来。Kuikly 的工程结构跟标准 Kotlin Multiplatform 项目类似,但多了一层 UI 描述层。我用的是官方推荐的工程模板,目录结构大致是这样:
harness-pocket/ ├── shared/ # 共享模块:数据模型、协议、连接层 │ ├── commonMain/ # 跨端通用代码 │ ├── androidMain/ # Android 平台特定实现 │ └── iosMain/ # iOS 平台特定实现 ├── androidApp/ # Android 壳工程 ├── iosApp/ # iOS 壳工程 └── build.gradle.ktsshared 模块是整个项目的核心,协议定义、连接层、状态层全在这里。androidApp 和 iosApp 只是薄薄的壳,负责启动 Kuikly 的渲染引擎,把 shared 里的页面挂上去。
初始化的时候有个关键配置:在 shared 的 build.gradle.kts 里开启 kotlinx.serialization 插件,否则@Serializable注解不生效。这个插件版本要和 Kotlin 版本对齐,我用的 Kotlin 1.9.22 配 serialization 1.6.2,实测稳定。
4.2 Host 协议的完整消息定义
协议是整个项目的骨架,我把核心消息类型列一下,方便你直接参考:
| 消息类型 | 方向 | 用途 | payload 结构 |
|---|---|---|---|
connect | C→S | 建立会话 | {clientVersion, sessionId?} |
resume | C→S | 断线恢复 | {lastSeq} |
prompt | C→S | 发送输入 | {text, modelParams} |
cancel | C→S | 取消当前生成 | {requestId} |
token | S→C | 流式输出片段 | {requestId, seq, text} |
tool_call | S→C | 工具调用通知 | {requestId, toolName, args} |
done | S→C | 生成完成 | {requestId, totalTokens} |
error | S→C | 错误通知 | {code, message} |
ping/pong | 双向 | 心跳 | {} |
每条消息都套在 HostMessage 信封里。seq 字段是服务端全局递增的序号,用于断线恢复时定位补发起点。requestId 用于关联一次完整的生成请求——一次 prompt 可能触发多个 token、多个 tool_call,最后以 done 或 error 收尾,全靠 requestId 串起来。
4.3 连接层的完整实现
连接层的核心是 WebSocket 的生命周期管理。我用 Ktor 的 WebSocket 客户端,因为它在多平台上都有支持,且 API 统一。核心代码结构如下:
class HarnessClient(private val scope: CoroutineScope) { private var session: DefaultClientWebSocketSession? = null private val _messages = MutableSharedFlow<HostMessage>(extraBufferCapacity = 64) val messages: SharedFlow<HostMessage> = _messages suspend fun connect(url: String) { client.webSocket(url) { session = this startHeartbeat() for (frame in incoming) { if (frame is Frame.Text) { val msg = Json.decodeFromString<HostMessage>(frame.readText()) _messages.emit(msg) } } } } suspend fun send(msg: HostMessage) { session?.send(Frame.Text(Json.encodeToString(msg))) } }这里有几个细节值得说。MutableSharedFlow的extraBufferCapacity设成 64,是为了防止消息生产速度超过消费速度时丢消息。WebSocket 的 incoming 是一个冷流,for 循环会一直挂起等待消息,直到连接关闭。心跳的启动放在连接建立之后,取消放在连接关闭的 finally 块里。
重连逻辑我单独包了一层:
fun connectWithRetry(url: String) { scope.launch { var attempt = 0 while (isActive) { try { connect(url) attempt = 0 // 连接成功,重置重试计数 } catch (e: Exception) { attempt++ val delayMs = min(30_000L, 1000L * (1 shl min(attempt, 5))) delay(delayMs) } } } }退避策略用的是指数退避 + 上限:1 秒、2 秒、4 秒、8 秒、16 秒、30 秒封顶。为什么封顶 30 秒?因为无限增长的话,用户切回前台可能要等好几分钟才重连,体验太差。30 秒是个平衡点,既不会频繁重试打爆服务端,也不会让用户等太久。
4.4 会话状态管理与 UI 绑定
状态层的职责是把原始消息流转换成 UI 能直接用的状态。我定义了一个SessionState:
data class SessionState( val connected: Boolean = false, val currentOutput: String = "", val generating: Boolean = false, val toolCalls: List<ToolCallInfo> = emptyList(), val error: String? = null )状态层订阅连接层的 messages 流,用 when 分发处理:
init { scope.launch { client.messages.collect { msg -> when (msg.type) { "token" -> appendToken(msg) "tool_call" -> addToolCall(msg) "done" -> finishGeneration(msg) "error" -> handleError(msg) } } } }UI 层用 Kuikly 的声明式语法绑定状态。Kuikly 的 UI 描述跟 Compose 很像,但它是跨端的:
@Page class HarnessPage : Page() { override fun body(): ViewBuilder { val state by sessionStore.state.collectAsState() return { View { attr { flexDirection(Column) } Text { attr { text(state.currentOutput) } } if (state.generating) { Button { attr { text("取消") }; event { click { cancel() } } } } } } } }这里collectAsState()是 Kuikly 提供的状态订阅扩展,状态变化会自动触发重组。注意重组是局部的,只有依赖了变化状态的组件会重新渲染,不会整个页面重刷。
4.5 打包与部署
Android 端的打包没什么特别的,标准 Gradle 流程。但有个点要注意:Kuikly 的 shared 模块会打进 APK,体积会比纯原生大一些。我实测下来,一个空壳 Kuikly 应用大概 8MB 左右,加上业务代码和依赖,最终 APK 在 15MB 上下。如果对体积敏感,可以开启 R8 混淆和资源压缩,能压到 10MB 以内。
iOS 端需要 Xcode 环境,Kuikly 会生成一个 framework 供 iOS 壳工程引用。这一步在 Windows 上做不了,得有 Mac。如果团队里没有 Mac,可以先只出 Android 版本,iOS 后面补。
服务端这边,Harness 本身跑在本地或者局域网服务器上。移动端要连上,得保证手机和服务端在同一个网络,或者服务端有公网可达的地址。我测试的时候用的是局域网 IP,正式用的话建议配一个内网穿透或者反向代理,把 WebSocket 的 wss 端点暴露出去。这里不展开讲网络配置,只提醒一点:WebSocket 走反向代理时,要确保代理层开启了 Upgrade 头透传和足够长的超时时间,否则连接会被代理层掐断。
5. 常见问题与排查技巧实录
5.1 连接建立失败排查表
连接问题是最常见的,我整理了一张速查表,按现象倒推原因:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 一直连不上,无报错 | 地址或端口写错 | 用 Postman 的 WebSocket 功能先测通 |
| 连上后立刻断开 | 服务端协议不匹配 | 抓包看服务端返回的关闭帧原因码 |
| 连上后 30 秒断开 | 心跳未生效 | 检查心跳定时器是否启动 |
| 切后台再回来断开 | 系统回收了连接 | 实现前台恢复时的主动重连 |
| 部分消息收不到 | 缓冲区溢出 | 增大 SharedFlow 的 buffer 容量 |
Postman 测 WebSocket 这个技巧特别实用。在写客户端代码之前,先用 Postman 把连接建起来,手动发几条消息,确认服务端行为符合预期。这样能把"服务端问题"和"客户端问题"隔离开,省掉大量瞎猜的时间。
5.2 流式输出卡顿的优化实录
最初版本在低端机上滚动长输出时卡得厉害,我做了几轮优化:
第一轮,把每 token 刷新改成 16ms 批量刷新,帧率从 20 提到 40 左右。第二轮,发现文本太长时 Text 组件的测量耗时很高,改成分段渲染——把输出按段落拆成多个 Text 组件,只对最后一个段落做增量更新,前面的段落标记为不可变,跳过重组。这一轮把帧率提到了 55 以上。第三轮,给列表加了key稳定标识,避免滚动时组件被错误复用。
心得:Kuikly 的重组优化和 Compose 思路一致,核心就是缩小重组范围。状态变化时,尽量只让真正依赖该状态的组件重组,而不是整个页面。我一开始把整个输出字符串放在一个状态里,导致每次更新整个页面都重组,改成按段落拆分后性能立竿见影。
5.3 断线恢复的消息补发验证
断线恢复这个功能,测试起来比较麻烦,因为要模拟真实的断连。我的测试方法是:在服务端加一个调试指令,收到后主动关闭连接。客户端重连后发 resume,服务端补发。验证的时候对比补发前后的输出是否连续,有没有丢 token 或者重复。
踩过的坑是序号重复。有一次服务端补发时把断连瞬间正在处理的那条消息也补了,导致客户端收到重复 token。解决办法是客户端在 resume 时带上lastSeq,服务端补发seq > lastSeq的消息,严格大于,不含等于。这个边界条件一定要测。
5.4 多端一致性的注意事项
Kuikly 号称一套代码多端跑,但实际开发中还是有一些平台差异要注意:
- 字体渲染:Android 和 iOS 的默认字体不同,同样的字号视觉大小有差异。我统一指定了字体族,避免各端不一致。
- 安全区域:iOS 有刘海和底部横条,Android 各厂商的挖孔位置也不同。Kuikly 提供了安全区域的内边距 API,布局时要留出这些空间。
- 返回手势:Android 有物理返回键和手势,iOS 只有边缘手势。页面栈的管理要兼容两种交互。
这些差异不影响核心逻辑,但影响体验细节。我的建议是尽早真机测试,不要等到功能全做完才发现布局在某个平台上错位。
6. 这套方案还能怎么扩展
把 Harness 装进口袋只是第一步。这套架构搭好之后,能扩展的方向其实不少。
最直接的是多会话管理。现在的实现是单会话,一次只能跟一个模型对话。改成多会话后,可以在手机上同时盯着几个任务的进度,哪个跑完了切过去看。协议层只需要在消息里加一个sessionId字段,状态层维护一个 Map 就行。
再往深了做,可以加语音输入。移动端有天然的语音优势,用系统语音识别把说的话转成文字,再走 prompt 指令发给 Harness。这样通勤路上真的可以"动嘴不动手"。
还有一个方向是通知推送。长任务跑完的时候,通过系统通知提醒用户。这个需要服务端配合,在 done 事件触发时推一条通知。Android 用 FCM,iOS 用 APNs,Kuikly 这边有对应的封装库。
我个人在实际操作中的体会是,跨端项目的价值不在于"一套代码跑所有平台"这个口号,而在于核心逻辑的复用。UI 层各平台该适配还是要适配,但协议、状态管理、连接逻辑这些真正复杂的部分,用 Kotlin 写一遍就够了。Kuikly 在这件事上做得比较务实,没有过度承诺,该暴露平台差异的地方就暴露,让开发者自己权衡。这种诚实的设计,反而让它在实际项目里更可靠。