1. 从一个真实翻车现场说起
去年我参与过一个内部工具的开发,核心功能是让一个智能助手帮用户处理多步骤的文档整理任务。上线第一周就收到一条反馈:用户上午让助手整理了一份会议纪要,中途去开会了,下午回来接着聊,助手很热情地回复“好的,我们继续”,然后——把整份纪要重新整理了一遍,之前确认过的修改全部丢失。
用户觉得“这助手是不是失忆了”,开发觉得“对话历史明明都在啊”。问题出在哪?出在我们把“对话能接上”当成了“任务能接上”。
这个坑不是个例。我后来跟不少做智能助手、自动化流程、客服系统的同行聊,发现大家几乎都踩过类似的雷:Session 恢复了,Task 却丢了;Task 还在,Action 的状态却对不上。表面上看是“上下文丢失”,根子上是三个本该分开管理的东西被揉成了一团。
这篇内容就围绕这个核心问题展开:Task、Session、Action 为什么必须分开管理。我会从设计思路、核心概念拆解、落地实现、常见坑排查几个角度,把这件事讲透。适合正在做智能助手、对话系统、自动化任务编排的开发者,也适合产品经理和测试同学理解背后的逻辑。不管你是刚接触这块,还是已经做过一两个项目,应该都能从中找到能直接抄作业的部分。
2. 三个概念先掰扯清楚:Task、Session、Action 到底各管什么
2.1 用生活类比理解三者的边界
先别急着上代码。我用一个生活场景把这三个概念讲明白。
想象你去餐厅吃饭。Session是你和服务员之间的“这次就餐过程”——从你坐下到结账离开,服务员记得你点了什么、说了什么、有什么忌口。Task是你这次就餐要完成的目标——“吃一顿满意的午饭”,它可能包含点菜、上菜、加菜、结账几个阶段。Action是每一个具体动作——“点一份宫保鸡丁”“加一碗米饭”“买单”。
关键点来了:你中途去了一趟洗手间(相当于 Session 断开),回来之后服务员还认识你(Session 恢复),但如果你换了一个服务员(Session 重建),新服务员需要知道的是“这桌的 Task 进行到哪一步了”,而不是“刚才那位服务员跟你聊了哪些闲话”。
很多系统的毛病就在于:把 Session 的对话历史当成了 Task 的全部状态。对话历史里确实有“用户说要加米饭”,但这句话散落在几十条消息里,系统没有一个结构化的地方记录“Task 当前处于‘已点菜、待上菜’阶段,Action‘加米饭’状态为待执行”。于是 Session 一断,Task 就散了。
2.2 三者的职责划分表
我把三者的核心职责整理成一张表,方便对照:
| 维度 | Session | Task | Action |
|---|---|---|---|
| 本质 | 一次交互会话的容器 | 一个要达成的目标 | 达成目标的具体步骤 |
| 生命周期 | 短,随连接/超时结束 | 中长,跨多个 Session | 短,执行完即结束 |
| 状态内容 | 对话历史、临时上下文 | 目标、阶段、进度、结果 | 执行状态、输入输出、重试次数 |
| 持久化要求 | 可丢失,可重建 | 必须持久化 | 必须持久化 |
| 恢复粒度 | 恢复对话流 | 恢复任务进度 | 恢复单步执行 |
| 典型问题 | 上下文超长、串话 | 进度丢失、重复执行 | 状态不一致、幂等性 |
这张表是我自己在设计时反复对照的。你会发现,Session 是“可丢弃”的,Task 和 Action 是“必须可靠”的。这个认知差异,直接决定了架构怎么搭。
2.3 为什么“能恢复对话”会给人“能恢复任务”的错觉
这里有个很微妙的心理陷阱。当用户重新打开对话,看到历史消息还在,助手还能接上话,第一反应就是“它记得我”。但“记得我说过什么”和“记得我要做什么、做到哪了”是两码事。
我见过一个典型实现:把整个对话历史塞进上下文,每次请求都带上。用户说“继续”,模型看到历史里有“帮我整理文档”,就接着整理。看起来没问题,但一旦任务涉及多步确认、外部工具调用、异步等待,这套就崩了。因为模型看到的只是“文本历史”,它不知道“第三步的审批已经通过了”“第五步的接口调用失败了需要重试”。这些状态不在对话文本里,而在 Task 和 Action 的结构化数据里。
注意:对话历史是“叙述性”的,任务状态是“结构化”的。用叙述性数据去驱动结构化流程,迟早出问题。
3. 核心设计思路:为什么要拆,拆了之后怎么协作
3.1 拆分的根本原因:生命周期不匹配
我总结下来,必须拆分的根本原因是生命周期不匹配。
Session 的生命周期通常由连接、超时、用户主动关闭决定,可能几分钟就没了。Task 的生命周期可能跨越几小时甚至几天——用户上午发起,下午继续,明天再确认。Action 的生命周期更短,一次工具调用可能几百毫秒就结束,但它的执行结果需要被 Task 记住。
如果三者共用一个生命周期,就会出现两种糟糕情况:要么 Task 被 Session 的超时拖死,用户回来发现任务没了;要么 Session 为了等 Task 一直不释放,资源被长期占用。这两种我在实际项目里都遇到过,前者导致用户投诉,后者导致服务器内存飙升。
3.2 拆分后的协作模型
拆开之后,三者通过 ID 关联,形成一个清晰的引用关系:
- Session 持有
session_id,并记录它当前关联的task_id列表 - Task 持有
task_id,记录它由哪个session_id发起,以及包含的action_id列表 - Action 持有
action_id,记录它属于哪个task_id,以及执行状态
这样设计的好处是:Session 断了,Task 还在,用户下次开新 Session,只要带上task_id,就能把任务进度捞回来。Action 失败了,Task 知道,可以决定重试还是跳过。三者各司其职,互不拖累。
3.3 一个容易忽略的点:Task 的“阶段”设计
很多实现只记录了 Task 的“完成/未完成”,这太粗了。我在实际项目里会强制给 Task 设计阶段(stage),比如“收集信息 → 确认方案 → 执行 → 校验 → 完成”。每个阶段有明确的进入条件和退出条件。
为什么要这样?因为用户说“继续”的时候,系统需要知道“从哪个阶段继续”。如果只有“未完成”一个状态,系统只能从头再来或者瞎猜。有了阶段,恢复就变成了“读取当前阶段,从该阶段的第一个未完成 Action 继续”。这个设计让恢复逻辑变得确定,而不是依赖模型去猜。
4. 落地实现:从数据结构到恢复逻辑
4.1 数据结构设计
先看数据结构。我用的是比较通用的关系型设计,你也可以换成文档型,但字段含义要保留。
-- Session 表:只管会话 CREATE TABLE sessions ( session_id VARCHAR(64) PRIMARY KEY, user_id VARCHAR(64) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, last_active_at TIMESTAMP, status VARCHAR(16) DEFAULT 'active' -- active / closed ); -- Task 表:管任务目标和进度 CREATE TABLE tasks ( task_id VARCHAR(64) PRIMARY KEY, session_id VARCHAR(64), -- 发起它的会话 user_id VARCHAR(64) NOT NULL, goal TEXT NOT NULL, -- 任务目标描述 stage VARCHAR(32) NOT NULL, -- 当前阶段 status VARCHAR(16) DEFAULT 'pending', -- pending/running/done/failed context_json JSON, -- 任务级上下文(结构化) created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); -- Action 表:管每一步 CREATE TABLE actions ( action_id VARCHAR(64) PRIMARY KEY, task_id VARCHAR(64) NOT NULL, seq INT NOT NULL, -- 步骤序号 name VARCHAR(64) NOT NULL, -- 动作名,如 fetch_doc input_json JSON, output_json JSON, status VARCHAR(16) DEFAULT 'pending', -- pending/running/success/failed retry_count INT DEFAULT 0, idempotency_key VARCHAR(128), -- 幂等键 created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );这里有几个字段是我踩坑后加的,值得单独说。
context_json放在 Task 上而不是 Session 上,是因为任务级上下文(比如“用户确认过的文档版本号”)必须跟着任务走,不能跟着会话走。idempotency_key放在 Action 上,是为了防止重复执行——网络抖动导致的重试,如果没有幂等键,可能把“发邮件”执行两次。seq字段保证 Action 有明确顺序,恢复时按序找第一个未完成的。
4.2 恢复逻辑的实现
恢复逻辑是整个设计的核心。我把它拆成三步:
第一步:定位 Task。用户新开 Session 后,系统根据user_id查询该用户所有status != 'done'的 Task,展示给用户选择,或者如果只有一个进行中的任务,直接恢复。
第二步:定位阶段和 Action。读取 Task 的stage,然后查询该 Task 下status IN ('pending', 'running', 'failed')的 Action,按seq排序,取第一个。
第三步:决定恢复策略。这里要分情况:
- Action 状态是
pending:直接执行 - Action 状态是
running:说明上次执行到一半断了,需要先查外部系统的实际状态(如果有),再决定重试还是标记完成 - Action 状态是
failed:根据retry_count决定重试还是上报
def resume_task(task_id): task = load_task(task_id) if task.status == 'done': return {"msg": "任务已完成"} pending_actions = query_actions( task_id=task_id, status_in=['pending', 'running', 'failed'], order_by='seq' ) if not pending_actions: # 所有 Action 都成功,但 Task 没标记完成,补一个收尾 finalize_task(task_id) return {"msg": "任务已收尾"} action = pending_actions[0] if action.status == 'running': # 关键:先对账,再决定 real_status = check_external_status(action) if real_status == 'success': mark_action_success(action.action_id) return resume_task(task_id) # 递归处理下一个 else: reset_action_to_pending(action.action_id) execute_action(action) return {"msg": f"从步骤 {action.seq} 继续"}这段代码里最关键的是check_external_status。对于running状态的 Action,绝对不能盲目重试,必须先确认外部系统到底执行了没有。我见过太多因为盲目重试导致重复下单、重复发消息的事故。
4.3 Session 重建时如何不丢 Task
Session 重建的场景很常见:用户换了设备、清了缓存、或者单纯超时了。这时候新 Session 和旧 Task 的关联怎么建立?
我的做法是:Task 不依赖 Session 存活。Task 表里虽然有session_id,但那只是记录“谁发起的”,不是“谁拥有”。恢复时通过user_id来查,而不是通过session_id。这样即使用户的 Session 完全重建,只要user_id一致,Task 就能捞回来。
如果系统支持匿名用户,那就用一个持久化的设备标识或本地存储的task_id来关联。核心原则是:Task 的归属是用户,不是会话。
5. 常见问题与排查技巧实录
5.1 问题速查表
| 现象 | 可能原因 | 排查方向 | 解决思路 |
|---|---|---|---|
| 用户说“继续”,任务从头开始 | Task 状态没持久化,或恢复时没读 stage | 检查 tasks 表是否有记录,stage 字段是否更新 | 补持久化,恢复逻辑读 stage |
| 任务重复执行 | Action 无幂等键,重试逻辑粗暴 | 查 actions 表 retry_count 和 idempotency_key | 加幂等键,重试前对账 |
| Session 恢复后串话 | 多个 Task 共用一个 Session 上下文 | 检查 Session 与 Task 的关联是否一对多 | 上下文按 Task 隔离 |
| Action 卡在 running 不动 | 执行中断后无对账机制 | 查 running 状态的 Action 停留时长 | 加超时对账任务 |
| 用户换设备后任务丢失 | Task 绑定 session_id 而非 user_id | 检查查询条件 | 改为按 user_id 查询 |
5.2 三个我踩过的坑
坑一:把 Task 状态存在 Session 的上下文里。早期图省事,把“任务进行到哪了”直接写在对话历史的一条系统消息里。结果 Session 一超时,上下文清空,任务状态全没了。后来改成独立表,问题消失。教训是:状态要存在它该在的地方,别图省事塞进对话流。
坑二:Action 重试没有对账。有一次一个“发送通知”的 Action 因为网络超时被标记为 failed,系统自动重试,结果用户收到了两条通知。后来加了幂等键,并且在重试前先查一次“这条通知是否已发送”,才彻底解决。凡是涉及外部副作用的 Action,重试前必须对账。
坑三:Task 阶段设计太粗。最初只有“进行中”和“完成”,结果用户说“继续”时,系统不知道从哪继续,只能把整个任务重跑。后来细分成五个阶段,每个阶段有明确的 Action 列表,恢复就变得确定了。阶段是恢复的锚点,越细越可控,但也不要细到每个 Action 一个阶段,那样就失去意义了。
5.3 一个实用的调试技巧
如果你正在排查这类问题,我建议加一个“任务快照”日志:每次 Task 或 Action 状态变更时,把变更前后的完整状态写一条日志。这样出问题时,你可以直接回放整个任务的演变过程,一眼看出是哪一步状态没对上。
这个日志不用长期保留,保留最近 7 天就够排查用了。我靠这个日志定位过好几次“状态莫名其妙不对”的问题,比断点调试高效得多。
6. 关于扩展性的一点个人经验
这套 Task、Session、Action 分离的模型,最初我是为一个文档整理助手设计的,后来发现它几乎可以平移到任何多步骤任务场景:自动化审批流、智能客服工单、数据同步管道,甚至游戏里的任务系统。
扩展的时候,我一般会加两个东西。一个是Task 模板,把常见任务的阶段和 Action 列表预定义好,新任务直接实例化,减少运行时决策。另一个是Action 执行器注册表,每个 Action 名字对应一个执行函数,新增动作只要注册就行,不用改核心逻辑。
最后分享一个小技巧:如果你的系统里 Task 和 Action 的关系比较复杂,可以在 Task 上加一个progress字段,用“已完成 Action 数 / 总 Action 数”表示进度。这个字段对用户展示特别友好,用户一看就知道“哦,还剩两步”,比单纯说“进行中”体验好很多。而且这个字段可以在每次 Action 状态变更时顺手更新,成本很低。
这套东西我用了两年多,中间迭代过几版,核心思路一直没变:Session 管对话,Task 管目标,Action 管执行,三者用 ID 关联,各自持久化,恢复时按 Task 找阶段、按阶段找 Action。把这个想清楚了,剩下的都是工程细节。