跳到正文
astorlm
语言: 简体中文
← 地图

第 12 关

会话

智能体的历史活在内存里,而内存随程序一起结束。会话会一路把它写下来,于是对话能比进程活得更久:你可以明天接着聊,也可以让它分叉。
1/34 手风琴褶数:
  • user
  • assistant
  • tool_result
勇者需要想办法进入回声洞窟。Astor 带着一本冒险日志干活:磁盘上的一个会话文件,每条消息一出现就记下来。

EventBus

问题

第 2 关的历史是内存里的一个列表。程序一停(一次部署、一次崩溃、有人关掉标签页明天再回来),列表就没了,智能体从零开始,把它早就知道的事情再问一遍。

只在最后保存也不够:一次跑到一半失败的运行,恰恰是你最想查看、或者想从断掉的地方接着跑的那一次。

解决方案

给每段对话一个 id 和一个磁盘上的位置,把历史当成那个文件,而不是一个变量。三个动作就够了:

  • 边走边存

    每条消息一出现就写进会话文件,一条消息一行。中途就算崩溃,也什么都不会丢。

  • 继续

    用同一个会话 id 创建的新智能体,会在下一个请求之前把文件读回它的历史里。

  • 分叉

    把历史复制到一个新会话里去试别的做法。原来的会话保持原样。

JSONL,也就是每行一条 JSON 消息,是常用的格式:加一条消息就是加一行,写了一半的最后一行也很容易发现。Claude Code 和 Codex 也是这样保存自己的会话的,它们就是靠这个来继续之前的对话。

角色

还是那群熟悉的角色,这次踏上了冒险。

冒险日志 会话文件
每个会话 id 一个存档栏,每条消息一个标记,一出现就写下。
手风琴 历史
智能体在内存里拥有的东西。游戏一关机它就空了;日志不会。
CONTINUE 继续
用同一个 id 的新智能体,把日志读回它的历史里。
COPY A QUEST 分叉
第二个存档栏,从第一个的副本开始。之后各自增长。
Astor 循环
照常跑循环,每条消息都写进日志。
小镇 工具
长老、守卫和道具店:talk_to 和 buy。

代码

使用 astorlm:传入一个 sessionId 和一个 FileSessionManager。智能体创建时会加载这个会话,之后每条消息都会保存;新的 id 会开一个空会话。fork() 会返回一个基于会话副本的新智能体。

从零手写:第 2 关的循环,先从一个 JSONL 文件读出历史,每条新消息再追加到文件末尾。分叉就是复制文件。

import { FileSessionManager, OpenAIProvider, createLocalAgent, tool } from 'astorlm'
import { z } from 'zod'

const talkTo = tool({
  name: 'talk_to',
  description: 'Talk to someone in town. Returns what they say.',
  schema: z.object({ npc: z.string() }),
  execute: async ({ npc }) => town.talk(npc), // your code
})

const buy = tool({
  name: 'buy',
  description: 'Buy an item at the shop. Returns the price and the gold left.',
  schema: z.object({ item: z.string() }),
  execute: async ({ item }) => shop.buy(item), // your code
})

// The adventure log: quest-1.jsonl (one message per line) and quest-1.meta.json, in this folder.
const sessionManager = new FileSessionManager({ dir: './sessions' })

const quest = (sessionId: string) =>
  createLocalAgent({
    // Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy…
    provider: new OpenAIProvider({
      baseURL: 'http://localhost:11434/v1', // e.g. Ollama's default address
      model: 'your-model', // e.g. 'llama3.1', 'gpt-4o-mini'
      apiKey: 'YOUR_API_KEY', // local servers usually ignore it
    }),
    tools: [talkTo, buy],
    sessionId, // a new id starts an empty log; a known one loads it
    sessionManager,
  })

// Day 1. Every message is saved as soon as it's added, so a crash loses nothing.
const day1 = await quest('quest-1')
await day1.run('I need to get into the Cave of Echoes. Find out what it takes, and get what you can.')

// …the process ends. Day 2: a new agent on the same id reads the log back before answering.
const day2 = await quest('quest-1')
console.log(day2.getMessages().length) // 6: yesterday is in the history
await day2.run('I got the Silver Key from the mayor’s daughter. What now?')

// A fork: a new session with a copy of every message. quest-1 stays as it was.
const west = await day2.fork({ newSessionId: 'quest-2' })
await west.run('Suppose the key doesn’t fit. Is there another way in?')

注意事项

  • 继续一个会话,就要把一切重发一遍。一段长对话的第 30 天,背着前面 29 天。会话负责保留历史;压缩它(第 9 关)才能让它不超出上下文窗口。
  • 分叉复制的是历史,不是世界。火把只买过一次,但两个存档里都有它。工具发出的邮件、下的订单、写的文件,在每个分支里都是真的发生过。
  • 继续时检查最后一行。如果程序死在工具调用和它的结果之间,日志会以一个没人回应的调用结尾,而大多数服务商会拒绝这种请求。把它删掉,或者给它补一个错误结果。
  • 每个会话只能有一个写入者。两个进程同时往同一个文件追加,行会交错在一起。给每次运行一个自己的会话,或者加一把锁。
  • 会话文件是个人数据。里面有用户说过的每一句话和每一个工具结果。决定好它们放在哪里、谁能读、什么时候删除。