很多 Agent 单轮对话时聪明得像学霸:
用户:北京天气怎么样?
Agent:正在为你查询。
一进入多轮会话,画风突然变了:
用户:那上海呢?
Agent:请问你想查询上海的什么?
更刺激的是:
- A 用户问的内容,跑到了 B 用户的回答里;
- 新会话已经创建,Agent 还惦记着上一轮;
- 用户把“预算 5000”改成“预算 8000”,Agent 坚持按 5000 推荐;
- 页面传了一遍聊天记录,Checkpointer 又补了一遍,模型看到两份完全相同的历史。
这已经不是“记性不好”,而是上下文、状态和会话管理失控。
一、先分清:上下文、短期记忆和长期记忆
测试前,先别把所有历史数据统称为“记忆”。
| 概念 | 主要作用 | 常见生命周期 | 示例 |
|---|---|---|---|
| 页面聊天记录 | 展示消息 | 页面或本地存储 | 用户看到的历史气泡 |
| 当前上下文 | 提供给模型推理 | 单次请求 | System Prompt、历史消息 |
| Checkpointer | 保存运行状态 | 会话级 | 消息、节点状态、工具结果 |
| 长期记忆 | 跨会话保存信息 | 用户级或业务级 | 用户偏好、常用地址 |
| 业务数据 | 保存真实业务状态 | 由业务决定 | 订单、工单、审批记录 |
一个重要原则
页面看到了历史,不等于模型拿到了历史;模型拿到了历史,也不等于系统应该永久记住历史。
例如:
- “我刚才说的预算是多少?”依赖当前会话上下文;
- “以后都用中文回复我。”可能属于长期偏好;
- “帮我创建了一张工单。”属于业务数据;
- “工具节点已经执行到哪一步。”可能由 Checkpointer 保存。
测试工程师要验证的不只是“记住没有”,还要验证:
- 该记的是否记住;
- 不该记的是否清除;
- 更新后的信息是否覆盖旧信息;
- 不同用户、租户和会话之间是否隔离。
二、单轮对话测试:先确认每一轮能独立站稳
单轮测试主要验证:只根据当前输入,Agent 能否给出正确响应。
例如:
用户:查询订单 A1001 的物流状态需要检查:
- 是否正确理解当前问题;
- 是否携带正确用户身份;
- 是否调用正确工具;
- 是否引用其他会话中的信息;
- 当前请求结束后是否错误写入长期记忆。
典型反例
用户第一次打开系统:
用户:帮我推荐一款手机。 Agent:按照你之前 5000 元的预算……如果这是新用户、新会话,而且用户从未提供过预算,那 Agent 很可能:
- 读取了错误的用户记忆;
- 复用了测试环境中的历史状态;
- 使用了共享缓存;
- 出现了会话串线。
这类问题的危险程度,远高于“回答不够优雅”。
三、多轮对话测试:Agent 能不能听懂“它、那个、刚才的”
多轮对话的核心,是验证 Agent 能否正确解析上下文中的省略、指代和条件。
基础用例
用户:查询北京今天的天气。 Agent:北京今天晴,最高温度 28℃。 用户:那上海呢?第二轮中的“上海”应继承:
- 查询动作:天气查询;
- 时间条件:今天;
同时覆盖:
- 城市:北京 → 上海。
测试矩阵
| 场景 | 第一轮 | 后续输入 | 预期 |
|---|---|---|---|
| 指代继承 | 推荐电脑 A | 它支持扩展内存吗 | “它”指向电脑 A |
| 条件补充 | 推荐北京酒店 | 要能带宠物 | 保留北京,增加宠物条件 |
| 条件修改 | 预算 5000 | 改成 8000 | 使用 8000 |
| 条件删除 | 只看五星酒店 | 不限制星级了 | 移除星级条件 |
| 对象切换 | 查询订单 A | 再看看订单 B | 后续围绕订单 B |
| 话题切换 | 查询天气 | 帮我写请假邮件 | 不应继续调用天气工具 |
| 回到旧话题 | 先聊订单,再聊天气 | 回到刚才的订单 | 正确恢复订单上下文 |
多轮测试不能只验证 Agent“还记得”,还要验证它是否知道:
什么时候继承、什么时候覆盖、什么时候清空。
四、条件修改测试:新信息必须压住旧信息
Agent 最常见的上下文 Bug,是“初恋条件忘不掉”。
用户:帮我推荐一台 5000 元以内的笔记本。 用户:预算改成 8000 元。 用户:主要用于视频剪辑。最终条件应为:
{"budget":8000,"usage":"video_editing"}而不是:
{"budget":[5000,8000],"usage":"video_editing"}更不能在推荐理由中继续写:
考虑到您的预算不超过 5000 元……重点测试三种更新方式
1. 覆盖
预算从 5000 改成 8000预期:旧预算失效。
2. 追加
还需要支持触控预期:保留原条件,新增触控要求。
3. 删除
不限制品牌了预期:移除品牌限制,而不是把品牌设置成字符串“不限制”。
自动化断言示例
state=get_agent_state(thread_id)assertstate["budget"]==8000assertstate["usage"]=="video_editing"assert5000notinstate.get("active_constraints",[])除了检查最终回答,还要检查 Agent 内部的结构化状态。否则回答看着正常,下一轮可能又把旧条件“考古”出来。
五、新建会话测试:开新聊天,不是换个聊天框继续失忆式表演
点击“新建会话”后,通常应生成新的thread_id或session_id。
测试步骤:
会话 A:我的预算是 8000 元。 会话 A:记住我主要做视频剪辑。 新建会话 B:我的预算是多少?预期结果取决于产品定义:
- 如果预算只属于会话上下文:会话 B 不应知道;
- 如果预算已明确写入长期记忆:会话 B 可以知道;
- 如果产品没有长期记忆功能:绝不能凭空回答 8000 元。
测试时必须先确认记忆边界
建议产品明确以下规则:
| 信息 | 是否跨会话 |
|---|---|
| 当前任务的临时条件 | 否 |
| 未完成工作流状态 | 根据产品设计 |
| 用户语言偏好 | 可以 |
| 用户明确要求记住的信息 | 可以 |
| 敏感数据 | 默认不应长期保存 |
| 其他用户的信息 | 永远不可以 |
“新建会话”不是简单清空页面,而应同步处理:
- 前端消息列表;
- 后端会话标识;
- Checkpointer 状态;
- 缓存键;
- 工具调用中的会话参数。
六、用户与租户隔离:串线一次,安全同学连夜上线
1. 不同用户隔离
准备两个用户:
用户 A:我的项目代号是“猎鹰”。 用户 B:我的项目代号是什么?用户 B 不应得到“猎鹰”。
需要重点检查:
- 缓存是否只按
thread_id区分; thread_id是否可能被用户猜测;- 服务端是否校验会话归属;
- 长期记忆查询是否包含
user_id; - 日志和向量库是否混用了用户数据。
2. 不同租户隔离
多租户系统中,推荐使用类似的隔离键:
tenant_id + user_id + thread_id不能只依赖:
thread_id因为不同租户可能存在相同用户名、相同业务编号,甚至相同的会话编号。
高危测试用例
| 场景 | 操作 | 预期 |
|---|---|---|
| 修改用户身份 | 用 B 用户访问 A 的 thread_id | 拒绝访问 |
| 修改租户标识 | 将 tenant_id 从 T1 改成 T2 | 无法读取 T1 数据 |
| 猜测会话编号 | 枚举相邻 thread_id | 不返回其他会话 |
| 并发登录 | A、B 同时发送请求 | 上下文严格隔离 |
| 缓存命中 | 两个用户发送相同问题 | 不复用私有上下文 |
会话串线不是普通功能 Bug,而是可能升级为数据泄露漏洞。
七、长对话测试:Token 满了,谁先被请出会议室
模型上下文窗口不是无限的。随着对话增长,系统通常会采用:
- 删除最早消息;
- 保留最近 N 轮;
- 压缩历史为摘要;
- 提取关键条件保存到结构化状态;
- 按相关性检索历史消息。
测试重点
在长对话开头设置关键条件:
第 1 轮:不能使用含花生的食材。 第 2~50 轮:讨论菜谱、人数、时间和价格。 第 51 轮:请输出最终菜单。即使第 1 轮原始消息已被截断,最终菜单仍不能出现花生。
哪些信息不应该随意截断
- 安全限制;
- 用户明确约束;
- 当前任务目标;
- 已确认的关键参数;
- 审批状态;
- 工具执行结果;
- 不可重复执行的业务操作记录。
建议验证三个层次
- 原始历史是否被截断;
- 关键条件是否被摘要或结构化保留;
- 摘要是否出现信息失真。
例如,原条件是:
预算不能超过 8000 元摘要却变成:
预算约 8000 元“不能超过”和“大约”只差几个字,账单可能差很多钱。
八、并发请求测试:别让两条消息在会话里“抢方向盘”
用户可能连续点击两次发送:
请求 A:把目的地改成上海。 请求 B:把出发日期改成下周一。如果两个请求同时处理,可能出现:
- 后写入覆盖先写入;
- 状态只保留一个修改;
- 消息顺序错乱;
- 同一个工具被调用两次;
- Checkpoint 版本冲突;
- A 的结果使用了 B 尚未提交的状态。
常见处理策略
- 同一会话请求串行执行;
- 使用状态版本号进行乐观锁校验;
- 为每个请求生成唯一
request_id; - 工具调用使用幂等键;
- 冲突时重试或提示用户;
- 明确消息排序规则。
自动化测试示例
results=awaitasyncio.gather(send_message(thread_id,"目的地改成上海"),send_message(thread_id,"出发日期改成下周一"))state=get_agent_state(thread_id)assertstate["destination"]=="上海"assertstate["departure_date"]=="下周一"assert_no_duplicate_tool_calls(results)并发测试不要只跑一次。很多竞态条件平时像隐士,压测时才突然下山。
九、应用重启测试:服务重启后,Agent 是续聊还是失忆
应用重启后的行为取决于 Checkpointer 的存储方式。
内存型 Checkpointer
特点:
- 实现简单;
- 适合本地调试;
- 服务重启后状态通常丢失;
- 多实例之间无法天然共享。
持久化 Checkpointer
可能使用:
- 数据库;
- Redis;
- 专用状态存储;
- 框架提供的持久化组件。
需要验证:
- 应用重启后是否恢复正确会话;
- 是否从正确节点继续执行;
- 已成功调用的工具是否重复调用;
- 中断时的半完成状态是否安全;
- Checkpoint 版本是否兼容新代码;
- 恢复失败时是否有降级策略。
尤其是支付、发邮件、创建工单等副作用操作,恢复时不能再来一遍:
Agent:刚才支付成功了。 Agent 重启后:怕您没付够,我又帮您支付了一次。这不是贴心,这是事故。
十、页面历史与 Checkpointer:最容易“双份投喂”
假设 Checkpointer 中已经保存:
用户:我的预算是 5000 元。 Agent:好的。下一轮页面又把完整历史提交给后端:
{"messages":[{"role":"user","content":"我的预算是5000元"},{"role":"assistant","content":"好的"},{"role":"user","content":"改成8000元"}]}后端再从 Checkpointer 恢复旧消息,就可能变成:
我的预算是 5000 元 好的 我的预算是 5000 元 好的 改成 8000 元可能造成的后果
- 消息重复;
- Token 消耗翻倍;
- 模型误判用户反复强调旧条件;
- 工具调用记录重复;
- 上下文更快达到窗口上限;
- 调试日志与真实会话不一致。
推荐方案
明确谁是运行时上下文的唯一事实来源。
方案一:Checkpointer 管历史
页面每次只提交:
{"thread_id":"t_1001","message":"预算改成8000元"}后端根据thread_id恢复历史。
方案二:客户端传完整历史
后端不再自动拼接同一份 Checkpointer 消息,或只将 Checkpointer 用于其他状态恢复。
方案三:消息去重
为每条消息设置唯一标识:
{"message_id":"msg_20250308_001","request_id":"req_10086","content":"预算改成8000元"}服务端根据消息 ID、请求 ID 和 Checkpoint 版本进行去重。
十一、高频面试题:页面已经保存聊天记录,为什么还需要 Checkpointer?
推荐回答
页面聊天记录和 Checkpointer 的职责不同。
页面状态主要用于展示聊天内容,可能保存在浏览器内存、本地存储或前端状态管理中;它不能可靠表示 Agent 后端的完整运行状态。
Checkpointer 主要用于保存 Agent 运行过程中的状态,例如:
- 多轮消息上下文;
- 当前工作流节点;
- 工具调用结果;
- 中断与审批状态;
- 可恢复的执行进度。
如果只依赖页面历史,刷新页面、切换设备、服务端恢复任务时,Agent 可能无法继续之前的运行流程。
但如果 Checkpointer 已经保存历史,页面又在每次请求中重复提交完整消息,就可能造成:
- 上下文重复;
- Token 浪费;
- 状态不一致;
- 重复执行工具。
因此系统应明确上下文的唯一事实来源,并通过thread_id、message_id、request_id和版本控制保证状态一致性。
一句话版本
页面历史负责“给人看”,Checkpointer 负责“让 Agent 接着干”;两边都传完整历史,就可能让 Agent 把同一段剧情看两遍。
十二、上下文与会话测试矩阵
| 测试维度 | 测试内容 | 核心断言 |
|---|---|---|
| 单轮对话 | 独立请求 | 不引用无关历史 |
| 多轮继承 | 指代、省略、条件补充 | 正确继承有效信息 |
| 条件修改 | 覆盖、删除、追加 | 最新条件生效 |
| 话题切换 | 新任务进入 | 不错误继承旧参数 |
| 新建会话 | 创建新 thread | 临时上下文清除 |
| 长期记忆 | 跨会话偏好 | 仅恢复允许的信息 |
| 用户隔离 | A、B 用户并行 | 数据不串线 |
| 租户隔离 | T1、T2 相同业务编号 | 租户数据严格隔离 |
| 长对话 | 超过上下文窗口 | 关键条件不丢失 |
| 并发请求 | 同一会话同时写入 | 无覆盖、无污染 |
| 应用重启 | 中断后恢复 | 状态正确且副作用不重复 |
| 历史去重 | 页面与后端同时存储 | 消息不重复 |
| 删除会话 | 删除后重新访问 | 无法恢复已删除数据 |
| 权限变化 | 用户权限被收回 | 历史权限同步失效 |
十三、自动化断言应该检查什么
不要只断言回答文本中出现某个关键词。
建议至少覆盖以下四层。
1. 回复层
assert"8000"inresponse.contentassert"5000"notinresponse.content2. 状态层
assertcheckpoint["budget"]==8000assertcheckpoint["thread_id"]==expected_thread_id3. 调用层
asserttool_call.arguments["budget"]==8000asserttool_call.arguments["user_id"]==current_user_id4. 存储与隔离层
assertquery_memory(user_b).does_not_contain(user_a_secret)assertcheckpoint.tenant_id==current_tenant_id还可以增加这些通用断言:
assert_no_duplicate_messages()assert_no_duplicate_tool_calls()assert_message_order_is_correct()assert_checkpoint_version_increases()assert_sensitive_data_not_cross_session()十四、更多高频面试题
1. 如何测试 Agent 使用的是最新信息?
先设置旧条件,再通过覆盖、删除和追加分别修改,最后检查:
- 最终回答;
- 工具参数;
- Checkpoint 状态;
- 摘要内容;
- 后续轮次引用结果。
不能只看当前一轮,因为旧条件可能在下一轮“诈尸”。
2. 如何测试会话隔离?
建立多个用户、租户和会话,同时写入容易辨认的唯一标记,然后交叉访问。
例如:
T1-U1:标记 ALPHA T1-U2:标记 BETA T2-U1:标记 GAMMA分别查询历史,确保只能获取自己权限范围内的数据。同时尝试篡改thread_id、user_id和tenant_id。
3. 长对话超出上下文窗口怎么办?
常见方案是历史截断、摘要压缩、结构化状态保存和相关性检索。测试重点不是要求保留所有原文,而是验证:
- 关键约束不能丢;
- 摘要不能篡改原意;
- 旧信息被修改后不能重新生效;
- 安全条件要比闲聊内容具有更高保留优先级。
4. Checkpointer 和长期记忆有什么区别?
Checkpointer 通常保存某个会话或工作流的运行状态,重点是“任务如何继续”。
长期记忆通常保存跨会话使用的用户信息,重点是“未来还要记住什么”。
简单理解:
- Checkpointer:上次任务干到哪了;
- 长期记忆:这个用户长期喜欢什么。
二者的生命周期、权限边界和清理策略都应分别测试。
十五、面试综合回答模板
我会从上下文正确性、状态更新、会话隔离、长对话处理、并发一致性和恢复能力几个方面测试。
首先验证单轮与多轮对话,包括指代解析、条件继承、覆盖、追加和删除,确保 Agent 始终使用最新有效信息。
其次验证新建会话后的清理策略,以及长期记忆和临时上下文的边界。
在安全方面,我会重点测试不同用户、不同租户和不同 thread 之间的数据隔离,并尝试篡改会话标识进行越权访问。
对长对话,我会验证截断、摘要和结构化状态是否保留关键约束。对并发和应用重启,则检查状态覆盖、Checkpoint 冲突、恢复正确性及工具调用幂等性。
最后,我会检查页面聊天历史与 Checkpointer 的职责边界,避免重复提交历史造成消息重复、Token 浪费和工具重复执行。
十六、速记口诀
上下文与记忆测试,可以记住这五句:
单轮不乱猜,多轮能接话;
旧条件让位,新会话清家;
用户不串门,租户不串线;
长聊不丢重点,并发不打架;
页面负责展示,Checkpoint 负责续命。
Agent 真正可靠的表现,不是“什么都记得”,而是:
该记的记得住,该忘的忘得掉,改过的别反悔,别人的绝不碰。