
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 而不是其他跨端方案跨端框架的选择本质上是在性能、生态、开发效率这个三角里找平衡点。我最初也考虑过 FlutterDart 写 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()、FlowHostMessage三个接口。状态层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 结构connectC→S建立会话{clientVersion, sessionId?}resumeC→S断线恢复{lastSeq}promptC→S发送输入{text, modelParams}cancelC→S取消当前生成{requestId}tokenS→C流式输出片段{requestId, seq, text}tool_callS→C工具调用通知{requestId, toolName, args}doneS→C生成完成{requestId, totalTokens}errorS→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 MutableSharedFlowHostMessage(extraBufferCapacity 64) val messages: SharedFlowHostMessage _messages suspend fun connect(url: String) { client.webSocket(url) { session this startHeartbeat() for (frame in incoming) { if (frame is Frame.Text) { val msg Json.decodeFromStringHostMessage(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 能直接用的状态。我定义了一个SessionStatedata class SessionState( val connected: Boolean false, val currentOutput: String , val generating: Boolean false, val toolCalls: ListToolCallInfo 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 用 FCMiOS 用 APNsKuikly 这边有对应的封装库。我个人在实际操作中的体会是跨端项目的价值不在于一套代码跑所有平台这个口号而在于核心逻辑的复用。UI 层各平台该适配还是要适配但协议、状态管理、连接逻辑这些真正复杂的部分用 Kotlin 写一遍就够了。Kuikly 在这件事上做得比较务实没有过度承诺该暴露平台差异的地方就暴露让开发者自己权衡。这种诚实的设计反而让它在实际项目里更可靠。