别再把聊天记录当 Agent 的“状态存储”了
用户从购物车里删掉了一件商品。五分钟后,你的 AI agent 却推荐他买回刚删掉的那件。你翻开日志——模型没有幻觉,它完全按指令行事。问题出在:购物车的变更根本没同步回消息历史,agent 一直在拿过期上下文做判断。
这就是把一串 [{role: "user"}, {role: "assistant"}] 当后端执行环境用的后果。
AI 行业花了两年时间,重新发现数据库为什么存在。OpenAI 的 /v1/chat/completions 接口是一个 REST 序列化格式,不是计算架构,更不是有限状态机。可大多数团队构建 agent 工作流的方式,就是拿一个 while 循环套在只追加的聊天日志外面,然后听天由命。
展示层不是后端
你的应用本身是有结构化状态的:当前用户是谁、购物车里有什么、用户走到流程的哪一步、数据库里有哪些数据。而 LLM 看到的只是扁平的消息数组。这两者不断发生偏差,而你——开发者——就是那个负责调和两边的人。
当你把消息数组当作事实来源(source of truth)时,你是在强迫一个展示层的数据结构去充当控制流。状态一旦漂移——它一定会漂移——调试就意味着逐条翻消息数组,去推断 agent 在第四步时自以为的状态是什么。没有别的办法。扁平模型把结构信息抹掉了。
这并非新问题。当你把高延迟的 LLM 调用、数据库写入和人工介入的暂停点混在一起时,你实际上在构建的是一个工作流。而这个正确抽象,我们早就给它起好了名字:持久化执行(durable execution)。
像 Temporal 这样的框架,做法是在每次网络调用时透明地给函数状态打检查点。你的代码写起来就像一个普通的同步脚本,其余交给框架处理。
当某个环节失败时,差异就会变得非常具体。假设 LLM 决定调用 charge_credit_card,但计费 API 超时了。在原始的对话循环里,整个流程直接崩溃。要重试,你得把提示词重新喂一遍,然后指望那个非确定性的模型做出和上次一样的决策。而在持久执行模型里,框架会捕获超时、等待,然后直接重试 charge_credit_card。LLM 不会被再次调用,工作流从暂停的位置精确恢复。
让 LLM 做它擅长的事:语义路由、数据抽取、决定下一步干什么。而真正的控制流、重试和状态管理,交给一个确定性的执行引擎。
工具调用不是消息
OpenAI 和 Anthropic 的 API 把工具调用表示成消息的一部分:一条带 tool_calls 字段的 assistant 消息,后面跟着 tool 角色的结果消息。作为序列化格式这没问题,但作为应用程序内部的一种架构模型,这就是错误抽象。
工具调用其实分三种语义完全不同的类型,而扁平的消息模型把它们全抹成一模一样。
读调用
search_products、get_cart、fetch_user_profile 这类调用只是观察世界,不改变任何东西。
一次读调用会产生状态:也就是智能体掌握的知识。在调用 search_products 之前,智能体不知道哪些商品匹配查询;调用之后它就知道了。而扁平模型把这种转变完全藏了起来。
最先出问题的是缓存。如果用户追问一个需要相同商品搜索结果的后续问题,在历史消息里,除了解析消息内容,没有干净的办法能判断结果是否已经存在。结果就是要么重复拉取,要么在智能体循环之外自己写去重逻辑。
上下文管理则是更慢的那个问题。随着历史变长,工具结果成了昂贵 token,需要裁剪。要把过期的搜索结果从扁平数组里丢掉,意味着对 JSON 编码的消息内容做字符串操作。这里没有任何类型化句柄能告诉你“这个工具带着这些参数被调用过,返回了这些结果”。
写调用
send_email、place_order、delete_file 会改变外部世界。
一次写调用(write call)执行完成,意味着某件事真的发生了。这个事实不该存放在客户端的对话数组里——客户端可以随意提供、修改,甚至重新构造这些内容。它应该落在服务端持有的记录中,客户端碰不到。
一旦出了问题,你需要知道:当时执行了哪些工具,传了什么参数,什么时间执行的。可变的对话数组记不下这些。扁平模型也没有表达“部分失败”的机制,你只能靠猜测:看看当前对话里有哪些工具消息来推断发生了什么。重放一个失败的工作流,不应该是重新唤起一次大语言模型(LLM)去从头问一遍,而应该从最后一个成功的步骤继续执行。
混合调用:真正没人愿意提的尴尬场景
reserve_inventory 和 charge_payment 就是这类尴尬的调用。
reserve_inventory 看起来像一次读操作——你只是在问“还有货吗?”,但它其实会占用库存(place a hold)。如果工作流在完成预留之后、下订单之前崩溃了,你会留下一个悬空的锁。在分布式系统里,这类问题通常用 Saga 模式和补偿事务(compensating transactions)来解决;而在一个简陋的智能体循环里,你只会多一条悬空的工具消息躺在数组里,什么都不做,库存却一直被占用着。
charge_payment 要读取当前余额,然后写入一笔扣费,也不是能干净回滚的操作。工作流中途失败时,它的恢复语义和纯粹的读或写完全不同。
扁平模型对这两种情况只有一种表示,差异最终只能靠智能体循环之外的业务逻辑去判断,而且代码里连类型化的访问方式都没有,根本取不到它需要的信息。
type ReadCall = {
kind: "read";
tool: string;
args: Record<string, unknown>;
result: unknown;
cachedAt?: Date;
};
type WriteCall = {
kind: "write";
tool: string;
args: Record<string, unknown>;
idempotencyKey: string; // Required for reliable retries
executedAt: Date;
result: unknown;
};
type HybridCall = {
kind: "hybrid";
tool: string;
args: Record<string, unknown>;
compensatingAction: string; // e.g., "inventory.release"
result: unknown;
};
type ToolCall = ReadCall | WriteCall | HybridCall;
缓存逻辑可以根据 kind === 'read' 分流;审计日志按 kind === 'write' 做筛选,所有变更操作直接落库,不再拼进提示词;回滚也有了明确的补偿动作,不用再从消息内容里猜。
大语言模型(LLM)的聊天日志并不是后端系统。正确做法是构建状态机和有向无环图(DAG),把 LLM 当作其中的非确定性计算节点来使用。