Agent 工程实习复盘 16|移动端为什么不能直接拉完整聊天记录:轻量 Thread Preview 与标题服务化

0 阅读10分钟

Agent 工程实习复盘 16|移动端为什么不能直接拉完整聊天记录:轻量 Thread Preview 与标题服务化

本文基于移动端接入 Agent 会话列表的一组改造整理。文中使用通用的移动端、Gateway、Agent Runtime 和 Thread 等名称,不涉及具体项目、公司、用户或服务地址。

本文关注会话列表和标题编辑,不讨论第 09 篇的旁路 AI 消息写回,也不讨论第 10 篇流式消息合并。

16.png

一、先说结论

移动端的最近会话列表不应该直接请求每个 Thread 的完整 statehistory

列表页只需要知道:这条会话是谁、最后更新时间、标题是什么、来自哪个入口。完整聊天记录却可能包含多轮消息、工具结果、文件引用、图片状态和中间执行数据。把两者放在同一个接口里,会让一个“显示 20 个标题”的操作变成多次大对象反序列化和网络传输。

这次改造把问题拆成两件事:

  1. 用轻量 Thread Preview API 服务列表,不返回完整消息历史。
  2. 把标题更新收敛到 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 文本,而是会话状态

标题看起来像一个很小的功能,实际上会被最近会话列表、分享页、跨端恢复、搜索和历史记录共同消费。

如果标题只保存在移动端状态里,会有三个问题:

  1. 切换设备或刷新后标题消失。
  2. 桌面端和移动端各自编辑,最终没有权威结果。
  3. 会话列表、分享快照和 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 动词偏好,也要考虑接入链路中真正存在的客户端和代理约束。

八、标题的读取优先级:不要每次都重新生成

预览接口读取标题时遵循“持久化优先、兼容回退”的思路:

  1. 优先读取 State 中已经保存且对用户可见的标题。
  2. 没有保存标题时,兼容性地从第一条用户消息推导一个标题。
  3. 两者都无法得到有效文本时,不返回这个空预览项。

这能兼容历史 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 SearchRuntime 搜索只选择 thread_idupdated_atvalues
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 和标题都成为明确的服务端契约后,多端会话体验才能既轻量又可恢复。