> Agent Harness Patterns 第 12 关，一条讲解 AI 智能体工作原理的模式路线。网页版：https://harnesspatterns.dev/zh/patterns/sessions · 全部模式（英文）：https://harnesspatterns.dev/llms.txt

# 会话

智能体的历史活在内存里，而内存随程序一起结束。会话会一路把它写下来，于是对话能比进程活得更久：你可以明天接着聊，也可以让它分叉。

## 问题

第 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 文件读出历史，每条新消息再追加到文件末尾。分叉就是复制文件。

**使用 astorlm**

```ts
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?')
```

**TypeScript**

```ts
// Sessions from scratch: a JSONL file per conversation. Node's standard library only.
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'

type Message = { role: string; content: unknown; [key: string]: unknown }
const DIR = './sessions'
const pathOf = (id: string) => `${DIR}/${id}.jsonl`

// 1. Load: one JSON message per line. No file yet means a new, empty session.
export function load(id: string): Message[] {
  if (!existsSync(pathOf(id))) return []
  return readFileSync(pathOf(id), 'utf8')
    .split('\n')
    .filter(Boolean)
    .map((line) => JSON.parse(line))
}

// 2. Save as you go: append each message the moment it exists. A crash mid-run loses nothing.
function append(id: string, message: Message): void {
  mkdirSync(DIR, { recursive: true })
  appendFileSync(pathOf(id), JSON.stringify(message) + '\n')
}

// 3. Fork: copy the file. From here on, each session only appends to its own.
export function fork(from: string, to: string): void {
  copyFileSync(pathOf(from), pathOf(to))
}

// 4. The loop from level 2, with its history loaded first and every new message written down.
export async function runSession(id: string, prompt: string, maxTurns = 10): Promise<string> {
  const messages = load(id)
  const add = (message: Message) => {
    messages.push(message)
    append(id, message)
  }

  add({ role: 'user', content: prompt })
  for (let turn = 1; turn <= maxTurns; turn++) {
    const choice = await chat(messages) // one model call, as in level 2
    add(choice.message)
    if (choice.finish_reason !== 'tool_calls') return choice.message.content ?? ''
    for (const call of choice.message.tool_calls ?? []) {
      add({ role: 'tool', tool_call_id: call.id, content: await runTool(call) })
    }
  }
  throw new Error(`No answer after ${maxTurns} turns`)
}

// Day 1, then day 2 on the same id, then a fork to try another way.
await runSession('quest-1', 'I need to get into the Cave of Echoes. Find out what it takes, and get what you can.')
await runSession('quest-1', 'I got the Silver Key from the mayor’s daughter. What now?')
fork('quest-1', 'quest-2')
await runSession('quest-2', 'Suppose the key doesn’t fit. Is there another way in?')
```

**Python**

```python
# Sessions from scratch: a JSONL file per conversation. Standard library only.
import json
import shutil
from pathlib import Path

DIR = Path("sessions")

def path_of(session_id):
    return DIR / f"{session_id}.jsonl"

# 1. Load: one JSON message per line. No file yet means a new, empty session.
def load(session_id):
    path = path_of(session_id)
    if not path.exists():
        return []
    return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line]

# 2. Save as you go: append each message the moment it exists. A crash mid-run loses nothing.
def append(session_id, message):
    DIR.mkdir(exist_ok=True)
    with path_of(session_id).open("a", encoding="utf-8") as log:
        log.write(json.dumps(message) + "\n")

# 3. Fork: copy the file. From here on, each session only appends to its own.
def fork(source, target):
    shutil.copyfile(path_of(source), path_of(target))

# 4. The loop from level 2, with its history loaded first and every new message written down.
def run_session(session_id, prompt, max_turns=10):
    messages = load(session_id)

    def add(message):
        messages.append(message)
        append(session_id, message)

    add({"role": "user", "content": prompt})
    for _ in range(max_turns):
        choice = chat(messages)  # one model call, as in level 2
        add(choice["message"])
        if choice["finish_reason"] != "tool_calls":
            return choice["message"].get("content") or ""
        for call in choice["message"].get("tool_calls", []):
            add({"role": "tool", "tool_call_id": call["id"], "content": run_tool(call)})

    raise RuntimeError(f"No answer after {max_turns} turns")

# Day 1, then day 2 on the same id, then a fork to try another way.
run_session("quest-1", "I need to get into the Cave of Echoes. Find out what it takes, and get what you can.")
run_session("quest-1", "I got the Silver Key from the mayor's daughter. What now?")
fork("quest-1", "quest-2")
run_session("quest-2", "Suppose the key doesn't fit. Is there another way in?")
```

## 注意事项

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

## 相关模式

- [2 · 智能体循环](https://harnesspatterns.dev/zh/patterns/agent-loop.md)
- [9 · 背包装满了](https://harnesspatterns.dev/zh/patterns/compaction.md)
- [13 · 记忆](https://harnesspatterns.dev/zh/patterns/memory.md)
- [14 · 每圈重新开始](https://harnesspatterns.dev/zh/patterns/fresh-laps.md)
