1. 为什么要单独做一个会话引擎:从单体 Demo 到生产系统的裂痕
先说说我踩过的坑。最初我做的 Agent 非常简单——一个用户消息进来,拼上历史记录,调一次大模型接口,拿到回复再返回给前端。这套逻辑在演示场景下跑得很顺,50 条测试消息、3 个工具函数,什么问题都没有。但一旦放到真实用户环境里,问题像开闸一样涌出来:有人连续对话 30 轮后上下文直接超限,有人触发工具后 Agent 卡在循环里出不来,有人同时开了两个页面导致同一个会话的数据互相覆盖。
这时候我才意识到,“AI Agent 会话引擎”不是一个可以靠临时补丁糊弄过去的组件。
1.1 单体结构在大模型应用里是怎么失控的
单体结构最典型的失控路径是这样的:所有逻辑都堆在一个服务里,模型调用、工具执行、会话存档、上下文拼接全混在一起。表面上看起来“代码量少、好维护”,实际上当会话数量上来之后,每一处改动都可能牵动其他模块。
我遇到过一个很典型的场景。当时我在工具执行逻辑里加了一个新的 API 调用,结果影响到了上游的上下文拼装——因为那个 API 返回的内容体量很大,直接把整段历史记录撑爆了。这类问题在单体结构里非常难排查,因为你不知道是会话存储的锅、还是模型调用的锅、还是工具执行的锅。一旦出问题,整个请求链路都会挂掉。
另一个问题是无法并发。单体结构里我最初用一个内存 Map 存会话状态,每个会话一把全局锁。结果发现多个工具同时执行时,锁竞争严重到接口响应时间翻了三倍。而且进程一重启,所有会话数据全部丢失,这是生产环境完全不能接受的。
1.2 会话引擎的边界往哪里切
真正帮我把问题理清楚的,是给系统画了一条清晰的分界线:会话引擎只负责“对话生命周期管理”,而把模型调用、工具执行、记忆存储都做成可替换的组件。
会话引擎的职责可以这样概括:
- 管理会话状态流转,从创建、活跃、等待工具、完成、失败到终止
- 维护消息的存储、索引、拉取,保证拉出来的历史记录是可追溯、可裁剪的
- 设计上下文窗口填充策略,决定哪些消息进模型、哪些消息进摘要缓存
- 编排 Agent 循环,包括模型调用、工具调用、结果回填和终止条件判断
- 处理并发写入、幂等重试、流式输出落盘
我给自己定了一个原则:会话引擎不直接写业务逻辑。比如说查询订单状态、生成报表、发送通知,这些动作全部通过工具接口暴露给 Agent,由会话引擎统一调度。这样一来,业务逻辑和大模型交互完全解耦,单测好写、问题好查、模块也能独立演进。
这个边界切分真正落地之后,我才把之前压在心头的几块石头搬开。后面所有的设计决策,都是围绕这条边界展开的。
2. 会话引擎的核心抽象:消息、状态与路由
确定了会话引擎的边界之后,我开始从数据模型层往下挖。这一步特别关键——如果你想支撑真正复杂的 Agent 行为,消息模型、状态机和路由不能靠随心所欲地写代码堆出来,它们决定了这个引擎能长多大、能扛住多复杂的场景。
2.1 消息模型为什么不能用“角色+内容”两字段糊弄过去
很多人初版的消息表只有两个字段:role 和 content。我一开始也这样,直到面临三个具体问题。
第一个问题是多轮对话中的工具调用信息如何存放。一次 Agent 推理可能先出一个“调用工具A”的中间动作,执行完工具后,再把工具结果拼回上下文继续推理。这些中间步骤如果只存 role+content,你会丢掉“模型想调哪个工具、传了什么参数、工具返回什么结果”这些关键信息,后面想复盘、想调试、想做断点续跑,完全没有依据。
第二个问题是消息之间的树状关系。真实对话中,用户可能会修改问题、撤回消息、重新触发某个分支。如果消息只是一条线性的列表,一旦出现分支就会混乱。
第三个问题是消息溯源。当模型回复出现明显错误时,我需要知道是哪一轮历史输入或哪个工具返回导致了这种错误。没有 message_id 和 parent_id 的关联,这个问题几乎无法回答。
具体落地时,我使用的消息结构可以简化为下面这个 JSON 结构:
{ "message_id": "msg_20250315_001", "session_id": "sess_10086", "parent_id": "msg_20250315_000", "role": "assistant", "content": "我来为您查询订单状态。", "tool_calls": [ { "tool_call_id": "call_001", "tool_name": "query_order_status", "arguments": {"order_id": "20250315001"} } ], "created_at": "2025-03-15T10:30:00Z" }工具执行结束后的消息结构略有不同,多出 tool_call_id 用来和上面的调用关联:
{ "message_id": "msg_20250315_002", "session_id": "sess_10086", "parent_id": "msg_20250315_001", "role": "tool", "tool_call_id": "call_001", "content": "订单状态:已发货,预计3月17日到达。" }我习惯把消息的原始数据(raw content)和“展示给模型用的格式化数据”(render content)分开存储。比如工具结果可能是很长的 JSON,但渲染给模型时可以做截断,甚至只保留关键字段。后面聊上下文管理的时候还会回到这个点。
2.2 会话状态机的四个核心状态
会话状态机是我整个引擎的骨架。没有状态机之前,会话只有“进行中”和“结束”两态,许多异常行为无法被准确归类和处理。后来我参考了业务流程编排的思路,把状态细化为五个核心态:
- PENDING:会话刚创建,等待用户第一条消息或等待上一步工具执行完成
- ACTIVE:模型正在推理,或正在等待模型返回结果
- WAITING_TOOL:模型已决定调用工具,引擎正在等待工具执行结果
- COMPLETED:本轮用户请求已经得到最终结果,会话暂时结束
- FAILED:执行过程中出现不可恢复的异常
这几个状态之间的流转逻辑用文字描述如下:新会话创建后进入 PENDING,收到用户消息后进入 ACTIVE;ACTIVE 状态下模型若返回工具调用意图,则转入 WAITING_TOOL;工具执行完成回填结果后回到 ACTIVE,继续触发下一轮模型推理;当模型给出最终答复且没有新的工具调用时,进入 COMPLETED;任一步骤抛出不可重试异常,则进入 FAILED。
WAITING_TOOL 这个状态对生产环境尤其重要。有了它,你就可以安全地实现“Agent 在某一步停了,然后隔几天再继续”这样的场景。我见过很多没有状态机的实现,工具调用到一半,进程重启后直接丢失上下文,这在真实业务中是不可接受的。
2.3 会话路由:意图分流与分支会话
会话引擎还需要考虑“路由问题”。一个会话里用户可能连续问多个意图,比如“帮我查下订单”和“顺便推荐一双鞋”,这两者显然应该走不同的工具链。如果每次都是无脑把新消息拼上去然后让模型自己决断,迟早会出现上下文污染。
我第一次上线时没有做任何路由,结果有用户先问天气再问电商订单,模型被前面的话题干扰,把天气 API 的参数硬套到订单接口上。那一次之后,我加入了一个意图路由层,原理可以简化为:
- 拿到用户当前消息,结合会话最近的意图标签,判断这是“推进当前意图”还是“切换新意图”
- 判断为推进时,维持当前工具链上下文,只追加新消息
- 判断为切换时,开启一个分支会话,让模型在新上下文中工作,主会话记录分支的入口和出口
这里需要注意,不要试图自己写一套 NLP 分类器。直接用大模型本身来做意图判断反而更省事,只不过场景不同要用不同的 prompt。路由结果还可以缓存起来,减少对同一意图的重复判断。
分支会话和主会话之间我采用引用关系而不是复制关系。分支会话只记录父会话 ID 和入口消息 ID,这样不会因为分支太多导致存储爆炸,回溯链路也始终清晰。
3. 多轮上下文管理的三件套:裁剪、摘要与结构化记忆
说到多轮对话,上下文管理就是会话引擎真正见功夫的地方。很多人只想着把历史消息一股脑塞给模型,等 token 超限了再临时截断。这种思路在多轮工具调用场景下会迅速翻车。我在实践中形成了三个层层递进的手段:先裁剪,再摘要,最后提取结构化记忆。
3.1 Token 预算分配:先定总额,再谈内容
每个模型都有上下文窗口上限。但我不建议把窗口填满,通常要预留出足够的余量给系统提示词、工具定义和本轮生成结果。我线上常用的比例大致如下:
| 用途 | 预算占比 | 说明 |
|---|---|---|
| 系统提示词 | 10% - 15% | 角色设定、回复风格、安全约束 |
| 工具定义描述 | 15% - 25% | Function Calling 接口的 JSON Schema 等 |
| 历史上下文 | 30% - 40% | 经过裁剪和摘要后的历史消息 |
| 当前输入 + 生成预留 | 30% - 40% | 当前用户输入 + 模型回复预留空间 |
这个比例不是死的,我可以根据场景调整。比如某些场景工具很多,工具定义占了太多 token,我会把不常用工具的详细描述换成一句话简介,由 Agent 需要时再动态加载完整说明。这样做有些取巧,但实测对工具选择准确率几乎没有负面影响。
3.2 滚动窗口 + 分层摘要:长对话不再爆炸
我采用的策略是“滚动窗口 + 分层摘要”双轨并行。
滚动窗口很好理解:只保留最近的 N 轮完整消息,更早的原始消息不直接进模型。但这个 N 不能只按“轮数”算,因为每轮消息的长度差异极大。如果某轮工具返回了一个 3000 token 的超长 JSON,这一轮就可能吃掉整个窗口预算。所以我实际是按 token 数量来滑动窗口的。
摘要层的触发条件有两个:一是历史消息超过窗口上限的 70%;二是会话轮数超过某个阈值(比如 12 轮)。一旦触发,引擎会对最早的那批消息调用一次轻量级的“摘要提示词”,把关键信息和结论浓缩成一段 200-300 字的文字存起来。后续请求里,这些文字替代原始消息进入模型。
摘要的存储也有讲究。我维护了一个“摘要层次”:第一层是最近 8 轮原始消息;第二层是 8 轮之前至更早的每 10 轮一组生成的摘要;第三层是整个会话的一次性全局摘要。每一层都是独立的记录,按时间顺序拼接即可。这样既不会丢失关键信息,也不会让最老的细节占满上下文。
3.3 结构化记忆:让 Agent 记住用户偏好而不是逐字复述
除了对话历史和摘要,我还发现了一个非常重要但容易被忽视的部分——结构化记忆。比如用户说“以后查订单默认显示最近一周的”,这句话如果流失在长对话的原始消息里,下次用户不带“最近一周”这几个字再问时,模型很可能就忘了。但如果我们把它提取成一条结构化的用户偏好记录,下次多轮推理时就能放回上下文。
我用的做法是,在每轮对话结束后触发一次“信息提取”,从当前轮次的消息中抽取实体、偏好和结论,写入独立的记忆字段。这个过程本身也可以做成一次轻量的模型调用。抽取出来的内容示例:
{ "memory_id": "mem_0001", "session_id": "sess_10086", "user_id": "u_12345", "memory_type": "preference", "content": "用户要求查订单时默认显示最近一周的数据", "source_message_id": "msg_20250315_001", "created_at": "2025-03-15T10:30:00Z" }把结构化记忆和长对话分开存储之后,模型看到的上下文由三部分组成:最近轮次的原始消息、早期消息摘要、以及相关的结构化记忆。这样一来,即使对话变长,真正重要的用户偏好也不会因为窗口滑动而丢失。
4. Agent 循环中的工具编排:从 Function Calling 到可中断的任务流
会话引擎最核心的运行逻辑就是 Agent 循环:模型推理、工具执行、结果回填、再推理,直到产出最终答案。这个循环里需要注意的坑非常多,我从工具描述、循环控制、结果消化三个角度来说。
4.1 工具描述协议:写 Schema 就是在写模型说明书
工具描述的质量会直接决定工具调用的成功率。我曾经因为一个参数名写得含糊,导致模型把日期格式传错,白白浪费了好几轮调试时间。工具描述不仅仅是“声明参数”,它更像是一份写给模型看的 API 使用说明书。
我总结出三个原则:
- 描述里写清楚“什么时候用这个工具”,而不是只写“这个工具是干什么的”
- 参数尽量简化,能不用嵌套结构就不用嵌套结构
- 每个参数用示例值说明格式,避免模型瞎猜
比如一个查询订单接口,我的 Function Calling 参数 Schema 会写成下面的格式:
{ "name": "query_order", "description": "根据订单编号或下单时间范围查询订单状态。当用户询问‘我的订单到哪了’‘发货了没有’时调用", "parameters": { "type": "object", "properties": { "order_id": { "type": "string", "description": "订单号,例如 20250315001,若用户未提订单号则设为 null" }, "start_date": { "type": "string", "description": "下单日期下限,格式 YYYY-MM-DD,若没有明确日期范围则设为 null" }, "end_date": { "type": "string", "description": "下单日期上限,格式 YYYY-MM-DD,若没有明确日期范围则设为 null" } }, "required": [] } }注意这里required我故意留空。因为用户很可能会问“我的订单到哪了”,但没给订单号。强行要求模型填 order_id 只会让它编造一个假订单号,后面的工具执行必然失败。
4.2 循环控制与中断恢复:避免 Agent 变成“死循环永动机”
Agent 循环如果不受控制,很容易陷入无限循环——模型反复调用同一个工具,或者在不同工具之间来回跳,拿不到最终答案也不退出。我早期就遇过过一次:某个会话里模型连续 7 次调用“查询天气”工具,每次传入的城市名越来越离谱,最后把工具跑挂了。
为了控制这类问题,我给循环加上了三层保险:
- 设置最大迭代次数,默认 8 轮,超过后强制结束并把已有信息整理成半成品回复给用户
- 检测重复工具调用。同一个工具在一段时间内连续调用超过 3 次且参数变化不大,就中断循环并提示模型“这个方向没有进展,请换个思路或直接向用户说明”
- 支持人工介入。当模型明确表达“需要用户确认”或循环异常时,会话状态进入 WAITING_TOOL 的长等待模式,由用户输入新指令后继续
中断恢复的另一个关键点是“断点续跑”。我在设计消息模型时保留了完整的工具调用链,所以重建现场很容易:只需根据 WAITING_TOOL 状态下的 tool_call_id 找到之前的上下文位置,追加新的用户指令作为一条消息即可继续推理。
4.3 工具结果的消化:不要让 JSON 原文污染上下文
工具执行结果回来之后,直接把它全量放进上下文等着继续传给模型,是我见过最普遍的做法,同时也是隐患最明显的。一个查询报表的工具可能返回 5000 行的 JSON 数组,模型读这些原始数据不仅浪费 token,还可能被里面的噪声带偏。
我采用“两级消化”方式。第一级是结构化压缩,把字段名缩短、拆掉 null 字段、截断超长数组。第二级是语义浓缩,让模型从工具结果中提炼出“对当前用户问题最有价值的信息摘要”,只把摘要作为 tool 消息回填到上下文。
需要说明的是,这里的语义浓缩需要和真正的工具调用协议配合好。工具消息本身在协议层必须保留一份原始结果的映射,所以我会把压缩后的数据放进模型上下文,原始结果则存到独立的工具执行记录表里,随时可以回溯。
5. 并发写入、幂等重试与流式输出的工程化处理
会话引擎的上层逻辑稳定之后,工程化的另一批问题紧跟着浮出水面。这章讲三个最影响生产环境体验的点:并发写入、工具调用的幂等性、以及流式输出的持久化。
5.1 会话锁与乐观锁:同一会话不允许同时跑两套 Agent
多个用户请求同时打到同一个会话上,是生产环境常见问题。用户可能在一个页面里点了两次发送,或者两个设备同时进入同一个会话,结果两条 Agent 循环并发执行,互相覆盖上下文,最终回复张冠李戴。
我一开始采用悲观锁:每个会话拿到一把锁,锁释放前其他请求直接排队等待。后来发现太慢,因为一次 Agent 循环要经历“模型推理 + 工具执行 + 再推理”,可能会持续几十秒甚至几分钟,后面的请求等太久。
最终方案是会话级乐观锁。每次写入会话状态时带一个版本号,如果版本号对不上就说明有其他更新发生过。具体做法是:
- 每个会话记录一个
version字段,每次状态变更后version + 1 - 更新时用 SQL 条件
WHERE session_id = ? AND version = ?作为原子条件 - 如果影响行数为 0,说明本次更新已过期,丢弃并通知前端刷新状态
这套方案还解决了“重复执行工具”的问题。只要状态版本变了,重试请求拿到的结果就不会重复写入,避免了一个订单被重复下单这种灾难性后果。
5.2 幂等键与重试表:工具执行失败后的“安全网”
工具执行失败后自动重试是必要的,但重试必须带幂等保护。尤其在支付、发消息这类有副作用的工具上,一次失败的请求如果被重试两次,可能就是三笔扣款、三条短信。
我的做法是给每次工具调用生成一个全局唯一的request_id(对应前面的 tool_call_id)。工具执行模块收到请求后,会先在“执行日志表”里查这个 ID 是否已经成功执行过。如果已有成功记录,直接复用结果,不再触发工具内部逻辑。
日志表字段大概是这样:
| 字段 | 说明 |
|---|---|
| request_id | 工具调用唯一标识 |
| session_id | 所属会话 |
| tool_name | 工具名称 |
| request_payload | 调用的完整参数 |
| status | pending / success / failed |
| result_data | 工具返回的结果快照 |
| retry_count | 已重试次数 |
| created_at / updated_at | 时间戳 |
幂等保护的另一个好处是,即使模型在连续两次 Agent 循环里发出了相同的工具调用,引擎也能识别出这是重复行为,直接返回之前的结果,避免重复副作用。
5.3 流式输出:一边生成一边落盘
大模型平台普遍支持流式输出,逐 token 返回结果。这在前端显示上是必需的能力,也是面部工程中的麻烦点:如果一个 HTTP 连接中途断了,用户看到的是半个回答,后端记录里却什么都没有。
我的处理策略是“边生成边落盘”。启动流式响应时,先创建一条消息记录,状态设为 generating;然后在每个 chunk 到达时,不仅把它推给前端,还同步写一份增量日志;当模型输出结束时,再把完整内容合并回消息记录里,状态改为 completed。
断线重连时,前端可以根据 message_id 拉取已经生成的部分,或者要求引擎从最后一个断点继续生成。这里的难点在于大模型平台的接口不一定支持“从指定位置续传”,所以更稳妥的做法是在断线时完整保留已生成的内容,然后开启一个新的请求,从断点往前拼接显示。实测下来,这种方案对用户观感几乎没有负面影响。
6. 压测与调优:一组实测数据和对应的调整方向
最后这部分我想给一组实际测试中积累的数据和调整方向,这部分内容在文档里很难找到,但恰恰是让会话引擎真正稳定下来的关键。
6.1 上下文管理策略的收益对比
我对同一组测试用例做了对比:一组是“全量历史直接拼接”,一组是“滚动窗口+摘要”策略。
| 场景 | 平均对话轮数 | 平均单轮延迟 | 上下文 token 峰值 | 工具调用成功率 |
|---|---|---|---|---|
| 全量拼接方案 | 20 | 9.2s | 31,000 | 58% |
| 裁剪+摘要方案 | 20 | 6.1s | 11,500 | 82% |
| 裁剪+摘要+结构化记忆 | 20 | 6.4s | 12,800 | 85% |
延迟下降的主要原因是模型需要阅读的 token 减少,推理速度自然提升。工具调用成功率提升则要归功于摘要把关键信息“浓缩”后,模型不再被大量无关历史噪音干扰。结构化记忆虽然略微增加了 token 成本,但换来的是用户偏好类问题的准确率明显上升。
6.2 循环控制参数的实际调优
循环控制参数最初我只是凭感觉设了一个最大轮数 8 和重复调用阈值 3,后来通过大量线上日志统计做了调整。下面是调整后的参数,供参考:
| 参数 | 初始值 | 调整后 | 调整原因 |
|---|---|---|---|
| 最大迭代次数 | 8 | 6 | 正常任务 90% 在 4 轮内完成,超过 6 轮的多为卡死循环 |
| 重复调用检测阈值 | 3 | 2 | 同一工具重复 2 次仍无进展即可主动干预 |
| 单次工具执行超时 | 60s | 30s | 大多数外部接口 10s 内返回,30s 已能覆盖多数慢接口 |
| 工具结果摘要触发长度 | 2,000 token | 1,200 token | 超过 1,200 token 后,模型推理准确率明显下降 |
这些参数在不同业务场景下会有浮动。如果你做的是一个工具链非常复杂的客服机器人,最大迭代次数可以适当放宽;如果是工具简单、响应要求快的场景,则应该收紧。
6.3 三个让我印象深刻的返工教训
第一个返工教训:一开始没有做会话级锁,结果线上出现同一会话消息覆盖。这个问题排查起来非常麻烦,因为表面上看是“模型回复丢失”,实际上是并发写入覆盖了生成记录。加了乐观锁之后,问题再也没有出现。
第二个返工教训:把工具结果的原始 JSON 直接塞进上下文,导致某个工具返回 3000 行数据时,模型不仅变慢,还在下一次工具调用里把无关字段当作参数传了出去。改成“压缩+摘要”之后,这个问题基本绝迹。
第三个返工教训:忽略了幂等保护。有一次模拟支付工具调用的测试里,网络抖动触发了自动重试,结果一个订单被重复提交了三笔扣款请求。虽然只是测试环境,但这个教训让我彻底重视起了幂等表。现在无论哪个新接入的工具,第一件事就是要求它支持 request_id 幂等。
这些教训有一个共同点——它们都不会在模型评测指标上体现,只在真实部署时爆雷。会话引擎这种底层组件,稳定性往往比花哨的功能更见功力。我现在的原则是先把状态、消息、幂等、上下文这四根柱子打牢,再考虑更多酷炫的能力。如果你也正在做一个多轮工具调用的 Agent 应用,希望这篇思路梳理能帮你少踩几个坑。