Agent 工程实习复盘 16|移动端为什么不能直接拉完整聊天记录:轻量 Thread Preview 与标题服务化
本文基于移动端接入 Agent 会话列表的一组改造整理。文中使用通用的移动端、Gateway、Agent Runtime 和 Thread 等名称,不涉及具体项目、公司、用户或服务地址。
本文关注会话列表和标题编辑,不讨论第 09 篇的旁路 AI 消息写回,也不讨论第 10 篇流式消息合并。
一、先说结论
移动端的最近会话列表不应该直接请求每个 Thread 的完整 state 或 history。
列表页只需要知道:这条会话是谁、最后更新时间、标题是什么、来自哪个入口。完整聊天记录却可能包含多轮消息、工具结果、文件引用、图片状态和中间执行数据。把两者放在同一个接口里,会让一个“显示 20 个标题”的操作变成多次大对象反序列化和网络传输。
这次改造把问题拆成两件事:
- 用轻量 Thread Preview API 服务列表,不返回完整消息历史。
- 把标题更新收敛到 Gateway,由服务端统一做所有权校验、标题规范化和 State 持久化。
核心原则是:列表读取的是会话索引,进入会话后才读取会话正文;标题是 Thread 状态的一部分,不是某个客户端的本地字段。
二、为什么完整 history 不适合列表页
一个完整 Agent Thread 不只有 human 和 AI 对话文本,还可能包含:
- 流式增量和多轮 AIMessage。
- Tool Call、ToolMessage 和长工具输出。
- 附件引用、转换资产和 Artifact 元数据。
- 运行时 State、标题来源、任务状态和中间件字段。
- 历史兼容数据,甚至是旧版遗留的大字段。
在桌面端,用户通常只看少量会话,网络和内存也相对充足;移动端列表常在启动、切换账号或返回应用时批量刷新,对体积、首屏速度和 WebView 内存更敏感。
如果列表页对每个 Thread 再请求一次 history,会产生典型的 N+1 问题:先查到 N 条 Thread,再发 N 次大请求。即使每条历史不大,N 次鉴权、序列化和网络往返也会放大延迟。
三、预览接口应该返回什么,不应该返回什么
轻量预览的返回结构只保留列表渲染和排序所需字段:
| 字段 | 用途 | 为什么需要 |
|---|---|---|
thread_id | 打开会话的稳定标识 | 列表项与详情页关联 |
updated_at | 最近排序和缓存失效 | 不用读消息正文也能判断新旧 |
title | 用户可读的列表标题 | 列表主信息 |
title_source | 手动标题或自动标题来源 | 避免客户端错误覆盖标题 |
source_agent_id | 入口或来源标识 | 多入口会话可正确展示 |
source_agent_name | 来源显示名 | 让列表不必再查详情 |
它明确不返回 messages、完整 state、工具结果和大文件内容。预览 API 的职责是提供“能不能展示、怎么排序”,不是提供“能不能继续上下文推理”。
graph TD;
A[移动端打开最近会话列表];
B[请求 Thread Preview Search];
C[Gateway 验证当前用户];
D[Runtime 只查询 thread_id updated_at values];
E[Gateway 按所有权过滤结果];
F[提取标题和来源字段];
G[返回轻量列表];
H[用户点击某个 Thread];
I[详情页再加载 State 或 History];
A --> B;
B --> C;
C --> D;
D --> E;
E --> F;
F --> G;
G --> H;
H --> I;
四、为什么预览查询仍必须经过 Gateway
预览数据很轻,不代表可以绕过安全边界。
Runtime 的 Thread Search 返回的是系统视角的候选结果,而当前用户能看到哪些 Thread,仍取决于 Gateway 的认证和所有权存储。预览接口的顺序是:先验证本地登录态,再向 Runtime 发起受限搜索,最后复用统一的 thread ownership 过滤逻辑。
这样有两个收益:
- 移动端不需要理解 Thread 所有权存储,也不能通过自行构造筛选条件越权查询。
- 列表接口和完整会话接口使用同一套“这个用户是否能访问这个 thread”的规则,避免出现“列表看得到但详情 403”或反过来的状态。
这是 Agent 产品里常见的一条原则:轻量接口可以裁剪数据,但不能裁剪鉴权。
五、标题不是 UI 文本,而是会话状态
标题看起来像一个很小的功能,实际上会被最近会话列表、分享页、跨端恢复、搜索和历史记录共同消费。
如果标题只保存在移动端状态里,会有三个问题:
- 切换设备或刷新后标题消失。
- 桌面端和移动端各自编辑,最终没有权威结果。
- 会话列表、分享快照和 Runtime State 显示不一致。
因此手动标题更新不能只改前端缓存,而是由 Gateway 代表用户写入 Thread State:
graph TD;
A[用户编辑标题];
B[客户端提交 title];
C[Gateway 验证登录和 Thread 所有权];
D[规范化标题文本];
E{标题有效};
F[返回参数错误];
G[写入 Thread State];
H[标记 title_source 为 manual];
I[返回规范化标题];
J[各端从 Preview 或 State 读取同一标题];
A --> B;
B --> C;
C --> D;
D --> E;
E -->|否| F;
E -->|是| G;
G --> H;
H --> I;
I --> J;
六、标题规范化具体解决了什么
服务端标题更新并不是原样写入。它至少做了以下处理:
- 去除模型思考标签,避免把内部 reasoning 片段带进列表。
- 合并多余空白和换行,避免标题在窄屏列表中撑开。
- 拒绝空标题,防止用户把可发现的会话变成无标题记录。
- 按统一最大长度截断,避免一个超长标题破坏列表布局。
- 写入
title_source = manual,让后续自动标题逻辑知道不能随意覆盖用户编辑。
这里的 title_source 很重要。没有来源字段时,客户端无法区分“这是用户改过的标题”还是“系统从第一条用户消息生成的临时标题”,很容易在刷新或自动生成时把手动修改覆盖掉。
七、为什么接口最终使用 POST,而不是强行坚持 PATCH
从纯 REST 语义看,改标题很像 PATCH。但跨端接入不只面对理想客户端:某些桥接层、移动网络栈或既有代理对 PATCH 的支持和配置不稳定,常常会造成方法不被转发、预检失败或难以统一重试。
这里选择使用明确的 POST /threads/{thread_id}/title,不是说 POST 比 PATCH 更“REST”,而是把目标放在稳定的业务契约上:
- URL 清楚表达标题更新这个命令。
- 请求体只接受标题字段。
- 服务端幂等地写入最终标题状态。
- 前后端和桥接端只需稳定支持一种方法。
接口设计不应只看理论上的 HTTP 动词偏好,也要考虑接入链路中真正存在的客户端和代理约束。
八、标题的读取优先级:不要每次都重新生成
预览接口读取标题时遵循“持久化优先、兼容回退”的思路:
- 优先读取 State 中已经保存且对用户可见的标题。
- 没有保存标题时,兼容性地从第一条用户消息推导一个标题。
- 两者都无法得到有效文本时,不返回这个空预览项。
这能兼容历史 Thread,同时避免在每次列表刷新时都重新调用模型生成标题。标题生成属于较重的运行时行为,列表页应该只消费已有事实,不承担生成工作。
九、前端缓存的边界:可以乐观,但必须能收敛
移动端可以先显示本地刚提交的标题或刚创建的预览项,减少用户等待感;但这只能是临时状态。
正确的收敛方式是:后端返回规范化后的标题后更新本地缓存,下一次 Preview Search 又以服务端 State 为权威。若请求失败,则回滚或提示错误,不能只保留一个永远不同步的本地标题。
graph LR;
A[用户修改标题];
B[前端临时显示新标题];
C[Gateway 持久化结果];
D{请求成功};
E[缓存收敛为服务端标题];
F[恢复旧标题并提示失败];
G[下次 Preview Search 返回权威值];
A --> B;
B --> C;
C --> D;
D -->|是| E;
D -->|否| F;
E --> G;
这条原则同样适用于新 Thread 的乐观预览:本地状态用于填补 Runtime 写入和列表搜索之间的短暂空窗,但不能成为长期会话索引的替代品。
十、验证:测轻量、权限和跨端语义
这组接口的验证重点不只是返回 200,而是确认它没有偷偷退化成完整 history 接口。
| 场景 | 期望结果 |
|---|---|
| Preview Search | Runtime 搜索只选择 thread_id、updated_at 和 values |
| Thread 无有效标题 | 不返回空白预览项 |
| Runtime 返回列表或带 threads 包装对象 | Gateway 都能稳定解析 |
| 用户无 Thread 所有权 | 不出现在结果中,标题更新也返回拒绝 |
| 手动标题包含多余空白或思考标签 | 服务端规范化后再保存 |
| 手动标题为空 | 返回明确校验错误 |
| 手动标题过长 | 按统一配置截断 |
| 标题更新成功 | State 同时保存标题和 manual 来源 |
| 客户端调用标题更新 | 使用 POST 且携带本地登录凭证 |
| 不支持的方法调用标题接口 | 不被意外放行 |
实现中补充了后端路由测试和前端 API 测试,覆盖预览解析、标题规范化、权限拒绝和 POST 方法约束。测试把“移动端能显示”拆成可验证事实,而不是只依赖人工点页面。
十一、几个看起来省事、实际会出问题的方案
11.1 列表页直接拉完整 State
短期开发最省事,长期会变成 N+1 请求和大对象传输。移动端首屏和 WebView 内存会最先受到影响。
11.2 只在客户端生成和保存标题
切换设备、分享会话或刷新页面后不一致。标题应是 Thread 的持久状态,不是某个页面的装饰字段。
11.3 为了轻量预览跳过鉴权
标题和更新时间同样是用户数据。轻量不等于公开,必须复用 Thread 所有权过滤。
11.4 每次列表刷新都调用模型生成标题
这会增加不必要的延迟和 Token 成本,还可能让同一会话标题不断变化。应优先读持久化标题。
11.5 只用前端乐观更新
网络失败、服务端截断或权限变化时,本地标题会和真实 State 分叉。乐观状态必须有服务端回包和下次查询的收敛点。
十二、面试时我会怎么讲
我会这样概括:
我参与过 Agent 移动端会话列表的轻量化改造。原本不能让列表页直接加载完整 LangGraph history,因为 Agent Thread 里可能包含工具结果、附件和运行时 State,移动端会出现 N+1 请求和内存放大。我设计了一个 Gateway Preview Search:Runtime 只返回 thread_id、更新时间和少量 values,Gateway 再执行用户所有权过滤并提取持久化标题。标题编辑也不再只改前端,而是通过 Gateway 做鉴权、去除思考标签、空白规范化、长度截断,并把 title 和 title_source 写回 Thread State。这样列表加载轻量,标题在多端、分享和刷新后保持一致。
结语
移动端适配 Agent 系统,不是把桌面端接口缩小一点。真正需要重新设计的是数据层级:列表读索引,详情读正文;客户端可以临时乐观,但会话事实必须回到服务端。
当 Thread Preview 和标题都成为明确的服务端契约后,多端会话体验才能既轻量又可恢复。