mini agent · 手搓一个 coding agent

教程默认使用英文,可通过顶部语言链接切换中文。源码注释、文档字符串和 CLI 提示全部使用英文;模型回复跟随用户语言。

mini agent

这份个人整理主要参考了 pi 和 Codex 的源码。

目录
第 1 章 · Agent Loop 把循环跑起来
第 2 章 · Tools 给 agent 一双手
第 3 章 · 变更队列 并发写文件不能乱
第 4 章 · 中断与转向 跑起来之后怎么插手
第 5 章 · 会话持久化 关掉还能接着聊
第 6 章 · 上下文压缩 历史太长怎么办
第 7 章 · 系统提示词与技能 让它知道自己在哪
第 1 章

Agent Loop

把循环跑起来
目标 写一个能自己连着干活的循环:问模型 → 它说要调工具 → 你执行 → 结果塞回去 → 再问一次,直到它不再要工具。

要解决的问题

原理

要理解 agent loop,先要接受一个有点反直觉的前提:大模型本身什么也做不了。它是一个纯粹的函数 —— 给它一段文本,它还你一段文本,然后就忘了。它没有记忆、没有手、没有状态。

所以「agent」不是模型的能力,是你写的那个循环。循环的唯一职责是:把模型输出的「我想做某事」翻译成真实动作,再把动作的结果翻译回文本喂给它。智能全在模型里,你的代码只负责搬运和记账。

工具调用到底是什么

模型说「我要调工具」时,它并没有执行任何东西。它只是在输出的 JSON 里多塞了一个 tool_calls 数组,每项形如 {id: "call_01...", function: {name: "read", arguments: "{...}"}}。其中 arguments 是被字符串化的 JSON 参数。这个数组的意思是「如果有一双手,我希望它去读这个文件」—— 执行与否完全取决于你。

理解这一点,整个架构就顺了:循环就是那双手。模型负责想,你负责做,做完再把结果告诉它。

先看清楚:OpenAI 风格的会话长什么样

在动手写循环之前,得先知道一次请求长什么样。整个协议其实只有一个入口:向 {base_url}/chat/completions 发一个 POST。请求体就三大块 ——

POST {base_url}/chat/completions
{
  "model":    "gpt-4o-mini",
  "messages": [                                  ← 整段对话,一个列表
    {"role": "system", "content": "You are a coding agent..."},
    {"role": "user",   "content": "帮我看看 config.py"},
    ...
  ],
  "tools":    [ ...工具声明... ]                  ← 模型能用的"手"
}

messages 是一个列表,每一条形如 {"role": ..., "content": ...}。role 一共就四种,把它们的分工记牢,后面每一章都要用:

  • system:系统提示词,给模型定身份和规则,只放在最前面一条(第 7 章专讲)。
  • user:用户说的话。
  • assistant:模型说的话;它想调工具时,这条消息会多出一个 tool_calls 数组。
  • tool:工具执行的结果,单独一条,靠 tool_call_id 认领它属于哪个调用。

回包也是一个 JSON。真正要紧的只有一条路径:choices[0].message —— 这就是模型这一轮的回复,下一轮你要原样塞回 messages 的就是它:

{
  "choices": [
    {
      "message": { "role": "assistant", "content": null,
                   "tool_calls": [ {"id": ..., "function": {"name": ..., "arguments": ...}} ] },
      "finish_reason": "tool_calls"        ← 同一层的另一个字段
    }
  ]
}

注意 finish_reason 和 message 是同一层的兄弟字段:一个装着模型说的话,一个装着「它为什么会停下来」的说明。这个区别马上就会用到。

背景:想核对字段,去哪看官方文档

上面画的请求和响应不是我编的,是 OpenAI 的 Chat Completions 接口 —— 整个接口只有这一个入口:向 {base_url}/chat/completions 发一个 POST,回来一个 JSON。想核对任何字段的确切含义,看这几页就够:

  • 请求体里每个字段(model / messages / tools / max_tokens …)的定义:api-reference/chat/create
  • 响应对象长什么样(choices → message → content / tool_calls,以及同层的 finish_reason):api-reference/chat/object
  • 四种角色(system / user / assistant / tool)怎么配合、完整对话示例:api-reference/chat
  • 工具调用这一整套约定(tools 声明、tool_calls 返回、tool_call_id 配对):guides/function-calling

对照文档你会发现:请求体里这里只用了四个字段 —— model、max_tokens、messages、tools;响应里只读 choices[0].message(文字在 content,工具调用在 tool_calls),其余字段一律忽略。协议很大,agent 需要的是很小一块 —— 流式、多模态、采样参数、logprobs 这些,先当它们不存在。

消息协议的硬约束

OpenAI 风格(chat/completions)对历史结构有强制要求。这条约束是后面好几章所有麻烦的根源,必须一开始就记住:

user      「帮我看看 config.py」
assistant {content: ..., tool_calls: [{id: "call_A", function: {name: read, ...}}]}  ← 只有意图
tool      {tool_call_id: "call_A", content: "..."}                     ← 必须配对
assistant 「这个文件里定义了一个 CONFIG 字典...」                           ← 收工

每个 tool_call 都必须有一条 tool_call_id 相同的 role 为 tool 的消息,一个都不能少、也不能多。后面也会用到这条规则:第 4 章中断时要补配对、第 6 章压缩时切点不能拆散配对,都是因为它。

还要注意 role 的用法:工具结果是单独的 role="tool" 消息,内容是明文(多为字符串化的 JSON),不是 assistant。这一点在第 6 章会导致一个非常隐蔽的坑。

那为什么循环的终止条件不看 finish_reason

上一节看到,finish_reason 和 message 是同一层的兄弟字段,而且它看起来很现成:模型要调工具时它是 "tool_calls",正常说完话时它是 "stop"。拿它来判断「循环要不要继续」几乎是最自然的写法 —— 但这恰恰是要避免的。

原因在于这两个字段的性质完全不同:

  • finish_reason 是服务商的元数据:它只描述「生成是怎么停下来的」,本身不是对话内容的一部分。不同厂商、不同兼容端点取值并不完全一致,实测有过差异。
  • 而 message.tool_calls 是你亲手写进历史的事实:模型到底有没有给出工具调用,看这个数组在不在、是不是空的就行,永远可靠。

所以本章的判据定为「assistant 消息里有没有非空的 tool_calls」,而不是看 finish_reason。用事实判断,不要用元数据判断 —— 这是个可以推广到很多地方的工程原则。

成本结构:为什么后面一定要压缩

注意请求体里的 messages 是完整历史,不是这一句。因为模型无状态,你不发它就不知道之前聊过什么。

于是有笔账要算:假设每轮产生 1 条消息,第 N 轮要发 N 条。N 轮下来累计发送量是 1+2+...+N ≈ N²/2。对话长度的增长会让成本呈平方级上升。

这就是第 6 章压缩存在的根本原因 —— 不是锦上添花,而是不压缩的话,稍微聊久一点就会撞上上下文窗口,API 直接报错。

设计取舍

本章新增或修改

本章从零搭出 mini_agent/ 这个包的骨架:config.py 把可调项集中一处,llm.py 是统一处理模型请求的地方,agent.py 写主循环,cli.py 让它在终端里真的跑起来。写完这章,你就有了一个能自己连着调工具干活的 agent —— 后面会在这份代码上逐步补充,尽量沿用已有的结构。

新增config.py所有可调参数(base_url、api_key、model、超时…)集中一处,都能用环境变量覆盖。
新增llm.pycall_llm() 发一次请求;extract_message / extract_text / extract_tool_calls 从响应里取消息、文字、工具调用。
新增agent.pyagent_loop():问模型 → 原样存消息 → 有 tool_calls 就执行 → 结果回灌 → 再问,直到没有 tool_calls。
新增cli.py终端 REPL:读一行、跑一轮、打印回答;-v 控制要不要打工具输出。
新增__init__.py包声明,让 mini_agent 成为一个可 import 的包。
新增__main__.py一行入口(from .cli import main),让 python -m mini_agent 能启动。

怎么实现

第 1 步:先写 config.py,把可变量集中
  • 查看 mini_agent/config.py 的 CONFIG,模型地址、密钥和模型名从环境变量读取。
  • 同一份配置也包含后续章节使用的会话和压缩参数,这里先关注模型请求部分。
配置与密钥检查mini_agent/config.py:10–53点击展开关键代码
mini_agent/config.py第 10–53 行
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# Configurable settings.
# Use environment variables to change providers without editing code.

CONFIG = {
    # Any compatible Chat Completions endpoint.
    "base_url": os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1"),
    "api_key": os.environ.get("OPENAI_API_KEY", ""),
    "model": os.environ.get("MODEL_ID", "gpt-4o-mini"),
    # Request parameters.
    "max_tokens": 8000,
    "request_timeout_s": 300,

    # Tool execution limits.
    "bash_timeout_s": 120,
    "max_output_chars": 50_000,

    # File reading.
    "max_read_lines": 2000,

    # Session persistence (chapter 5).
    # One JSONL file per session under the working directory by default.
    "session_dir": os.environ.get("MINI_AGENT_SESSION_DIR", ".mini_agent/sessions"),

    # Context compaction (chapter 6).
    # Approximate model context limit used to trigger summarization.
    "max_context_tokens": int(os.environ.get("MINI_AGENT_MAX_CONTEXT_TOKENS", "60000")),
    # Reserve room for the next request and response.
    "compaction_reserve_tokens": 8000,
    # Keep these recent user turns verbatim.
    "compaction_keep_recent_turns": 3,
}

# Literal shell-command blacklist.
# This demonstrates the idea of execution guardrails.
# Literal matching is easy to bypass; it is not a sandbox.
BLOCKED_PATTERNS = ["rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"]


def require_api_key():
    """Require an API key before starting an agent session."""
    if not CONFIG["api_key"]:
        raise SystemExit(
            'Missing OPENAI_API_KEY.\nSet the environment variables, for example:\n  $env:OPENAI_BASE_URL="https://api.openai.com/v1"\n  $env:OPENAI_API_KEY="sk-..."\n  $env:MODEL_ID="gpt-4o-mini"\n'
        )
第 2 步:写 llm.call_llm —— 模型请求入口
  • mini_agent/llm.py 的 call_llm 把 system 与历史拼成请求,并发送到 /chat/completions。
  • 请求返回前后会检查 signal;中断的细节放在第 4 章。

请求模块的导入与默认提示词(mini_agent/llm.py 第 9–21 行)

import json
import urllib.error
import urllib.request

from .config import CONFIG

# Fallback system prompt.
# Chapter 7 assembles behavior, environment information, and Markdown skills.
# This constant remains a minimal fallback for direct callers.
SYSTEM_PROMPT = (
    "You are a coding agent. "
    "Use the provided tools to solve the task. Act, don't explain."
)

call_llm 的 system 参数默认是 SYSTEM_PROMPT。CLI 会传入第 7 章组装的完整系统提示词;传入 None 时也会使用这个默认常量。

call_llm:发送模型请求mini_agent/llm.py:24–75点击展开关键代码
mini_agent/llm.py第 24–75 行
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
def call_llm(messages, tools, system=SYSTEM_PROMPT, signal=None):
    """Send one Chat Completions request and return the response dictionary.
    
    messages is the full conversation history; the model does not retain it
    between requests. tools contains function schemas. system=None uses the
    fallback prompt. signal performs cooperative cancellation checks."""
    # Chapter 4: check cancellation before sending.
    # Avoid sending a request after the user has already cancelled.
    if signal is not None:
        signal.check()

    # The protocol represents system instructions as a message.
    # Prepend it without changing the stored conversation history.
    system_text = SYSTEM_PROMPT if system is None else system
    full_messages = [{"role": "system", "content": system_text}] + messages

    payload = json.dumps(
        {
            "model": CONFIG["model"],
            "max_tokens": CONFIG["max_tokens"],
            "messages": full_messages,
            "tools": tools,
        }
    ).encode("utf-8")

    req = urllib.request.Request(
        f"{CONFIG['base_url']}/chat/completions",
        data=payload,
        method="POST",
        headers={
            "Content-Type": "application/json",
            "Authorization": f"Bearer {CONFIG['api_key']}",
        },
    )

    try:
        with urllib.request.urlopen(req, timeout=CONFIG["request_timeout_s"]) as resp:
            data = json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        detail = e.read().decode("utf-8", errors="replace")[:500]
        raise RuntimeError('LLM request failed {0}: {1}'.format(e.code, detail)) from e
    except urllib.error.URLError as e:
        raise RuntimeError('LLM connection failed: {0}'.format(e.reason)) from e

    # Chapter 4: check cancellation again after receiving the response.
    # Discard the response if cancellation occurred while waiting.
    # urllib's blocking read cannot be interrupted by this check.
    # Immediate transport cancellation would require a different request mechanism.
    if signal is not None:
        signal.check()

    return data
第 3 步:把「读响应」抽成三个小函数
  • 查看 extract_message、extract_text、extract_tool_calls;消息保留 content 和工具调用信息。
  • 下面的 JSON 用来对照响应结构,不是另一个阶段的代码文件。

① 模型只想说话:tool_calls 这一项根本不存在,content 是文字

{
  "id": "a5d97c0de647ad8fd550f43b9cd98d52",
  "object": "chat.completion",
  "model": "deepseek/deepseek-v4.1-flash",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "你好呀",
        "reasoning_content": "……(这里躺着模型的思考过程,这里不读取)"
      },
      "finish_reason": "stop"
    }
  ]
}

② 模型要调工具:多出一个 tool_calls 数组,content 是空的

{
  "id": "31627bb7940ad33d1b3bba47858e514b",
  "object": "chat.completion",
  "model": "deepseek/deepseek-v4.1-flash",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "",
        "tool_calls": [
          {
            "id": "call_00_wo91vwl3s4wkiiidgyvi9fln",
            "type": "function",
            "function": {
              "name": "bash",
              "arguments": "{\"command\": \"ls -la\"}"
            }
          }
        ],
        "reasoning_content": "……(同上,不读)"
      },
      "finish_reason": "tool_calls"
    }
  ]
}

这两段是从真实端点抓下来的原样回包(只省掉了 usage / created 这类字段)。盯住三件事:① 最外层是 choices 数组,本轮回复在 choices[0].message,而 finish_reason 是 message 的兄弟字段(不是它的儿子);② tool_calls 只在模型想调工具时才出现,这时 content 可能是 null(官方文档的说法),也可能是空字符串 ""(很多兼容端点这样,本机那个端点就是)—— 所以读文字一律写成 content or "";③ tool_calls 里的 arguments 是字符串而不是 dict,想当参数用还得自己 json.loads() 一次。

三个读响应的小函数mini_agent/llm.py:78–93点击展开关键代码
mini_agent/llm.py第 78–93 行
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
def extract_message(response):
    """Return the complete assistant message, including content and tool_calls."""
    return response["choices"][0]["message"]


def extract_text(response):
    """Return assistant text, using an empty string when content is null."""
    return extract_message(response).get("content") or ""


def extract_tool_calls(response):
    """Return tool calls, or an empty list when the assistant requests none.
    
    The loop uses these calls to decide whether to continue, rather than
    depending on provider-specific finish_reason metadata."""
    return extract_message(response).get("tool_calls") or []
第 4 步:写循环骨架 —— 本章的核心
  • 查看 mini_agent/agent.py 的 agent_loop:请求模型、保存回复、执行工具、回灌结果。
  • 工具实现集中在 mini_agent/tools.py;这里以 shell 工具为例,其他工具放在第 2 章。
  • 当前循环还包含中断、持久化和压缩的接入点,后面分开说明。

主循环模块的导入(mini_agent/agent.py 第 10–17 行)

from concurrent.futures import ThreadPoolExecutor
import json

from . import llm

from . import tools as toolbox
from .control import Aborted, Signal

默认事件回调(mini_agent/agent.py 第 30–54 行)

def default_emit(kind, **kw):
    """Print basic loop events; the CLI supplies its own renderer."""
    if kind == "text":
        print(kw["text"])
    elif kind == "tool_start":
        print(f"  → {kw['name']}({_brief(kw.get('input'))})")
    elif kind == "tool_end":
        pass  # Let the CLI decide whether to show long tool output.
    elif kind == "aborted":
        print('[Aborted] {0}'.format(kw.get('reason')))
    elif kind == "max_turns":
        print('[Turn limit reached: {0}]'.format(kw['limit']))


def _brief(inp, limit=70):
    """Summarize tool arguments on one line for terminal output."""
    if not isinstance(inp, dict):
        return repr(inp)[:limit]
    parts = []
    for k, v in inp.items():
        s = str(v).replace("\n", "\\n")
        if len(s) > 30:
            s = s[:30] + "…"
        parts.append(f"{k}={s}")
    return ", ".join(parts)[:limit]

shell 工具的声明(mini_agent/tools.py 第 36–53 行)

TOOLS = [
    _function(
        "bash",
        "Run a shell command.",
        {"command": {"type": "string"}},
        ["command"],
    ),
    _function(
        "read",
        "Read a file's text content.",
        {
            "path": {"type": "string"},
            "offset": {"type": "integer", "description": "1-indexed start line"},
            "limit": {"type": "integer", "description": "max lines to read"},
        },
        ["path"],
    ),

上面分别来自 agent.py 与 tools.py;工具实现不再放在 agent.py 内。

tool_bash:执行 shell 命令mini_agent/tools.py:101–124点击展开关键代码
mini_agent/tools.py第 101–124 行
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
def tool_bash(inp):
    """Run a shell command with a literal blacklist, timeout, and output limit."""
    command = str(inp.get("command", ""))
    if any(pattern in command for pattern in BLOCKED_PATTERNS):
        return "Error: Dangerous command blocked"

    try:
        proc = subprocess.run(
            command,
            shell=True,
            cwd=os.getcwd(),
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            timeout=CONFIG["bash_timeout_s"],
        )
    except subprocess.TimeoutExpired:
        return f"Error: command timed out after {CONFIG['bash_timeout_s']}s"

    output = f"{proc.stdout or ''}{proc.stderr or ''}".strip()
    if not output:
        return "(no output)"
    return output[: CONFIG["max_output_chars"]]
agent_loop:模型、工具与历史mini_agent/agent.py:195–245点击展开关键代码
mini_agent/agent.py第 195–245 行
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
        # Step 5: ask the model.
        emit("request", turn=turn, history_len=len(messages))
        response = llm.call_llm(messages, tools, system=system, signal=signal)

        # Step 6: save the complete assistant message.
        # Keep tool_calls as well as content.
        # Their IDs pair with tool results in the next request.
        _append(messages, llm.extract_message(response), on_append)

        text = llm.extract_text(response)
        if text:
            emit("text", text=text)

        calls = llm.extract_tool_calls(response)

        # Step 7a: no tool calls means the task is done.
        if not calls:
            emit("done", turns=turn)
            break

        # Step 7b: check cancellation after the assistant declares tools.
        #
        # Do not simply break: the assistant call is already in history.
        # Each declared tool call must receive a matching result.
        # Otherwise the next request has invalid message ordering.
        # Providers may report an unmatched tool_call_id.
        #
        # Append an interrupted result for every pending call before exiting.
        # One call requires one matching tool message.
        if signal.aborted:
            for c in calls:
                _append(
                    messages,
                    {
                        "role": "tool",
                        "tool_call_id": c.get("id"),
                        "content": f"Error: aborted ({signal.reason})",
                    },
                    on_append,
                )
            emit("aborted", reason=signal.reason)
            break

        # Step 8: execute tools and append one message per result.
        # Multiple calls need separate tool messages with their own IDs.
        # Keep their order consistent with the declared tool calls.
        results = execute_tool_calls(calls, signal=signal, emit=emit, executor=executor)
        for r in results:
            _append(messages, {"role": "tool", **r}, on_append)

    return messages
第 5 步:写 CLI,让它能真的跑起来
  • mini_agent/cli.py 的 build_parser 与 main 负责启动;交互输入由 App.repl 处理。
  • main 中一次性模式直接调用循环,交互模式进入 App。

CLI 的导入(mini_agent/cli.py 第 10–17 行)

import argparse
import os
import sys
import threading

from . import agent as agent_mod
from . import compaction, prompt, session as session_mod
from .config import CONFIG, require_api_key, setup_console

这些导入对应参数解析、后台线程、会话、提示词和循环调用,均来自当前 cli.py。

命令行参数与入口mini_agent/cli.py:270–312点击展开关键代码
mini_agent/cli.py第 270–312 行
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
def build_parser():
    p = argparse.ArgumentParser(
        prog="mini_agent",
        add_help=False,
        description='A minimal coding agent (educational project)',
    )
    p.add_argument("-h", "--help", action="help", help='Show this help message and exit')
    p.add_argument("-c", "--continue", dest="continue_", action="store_true",
                   help='Continue the most recent session')
    p.add_argument("--resume", metavar="SESSION_ID",
                   help='Resume a saved session')
    p.add_argument("--max-turns", type=int, default=None, metavar="N",
                   help='Maximum model calls per conversation turn')
    p.add_argument("-v", "--verbose", action="store_true",
                   help='Show tool output')
    p.add_argument("-p", "--print", dest="oneshot", metavar="PROMPT",
                   help='Run a task once and exit without entering interactive mode')
    return p


def main(argv=None):
    setup_console()
    args = build_parser().parse_args(argv)
    require_api_key()

    if args.oneshot:
        # One-shot mode runs the task and exits for script use.
        app = App(args)
        app.session.append("user", args.oneshot)
        agent_mod.agent_loop(
            app.session.messages,
            system=app.system_prompt,
            signal=app.signal,
            max_turns=args.max_turns,
            emit=app.render,
            executor=app.executor,
            on_append=app.session.persist,
            before_request=app.before_request,
        )
        return 0

    App(args).repl()
    return 0
第 2 章

Tools

给 agent 一双手
目标 把工具抽象成「名字 → 函数」的一张表。此后加工具只改一个文件,循环一行都不用动。

要解决的问题

原理

第 1 章的循环里,执行工具那段大概是这样的:

if name == "bash":     result = run_bash(inp)
elif name == "read":   result = read_file(inp)
elif name == "write":  result = write_file(inp)
else:                   result = "unknown tool"

这能跑。但它有两个问题,而且都会随着项目变大而恶化。

问题一:核心代码被工具列表绑架

每加一个工具就要改一次主循环。而主循环是整个项目最不该动的文件 ——它承载着后面所有章节的功能(中断、压缩、落盘),动它一次就要重新验证全部行为。

解法是经典的表驱动:把「名字 → 实现」存成一张字典,循环只写一行 submit(name, input)。加工具 = 改表,不改逻辑。这里把工具调用与具体实现分开。

问题二:工具失败会把 agent 弄崩

文件不存在、命令超时、路径越界 —— 这些在 agent 场景下都是常态,不是异常情况。它们随时会发生,因为调用方是一个会犯错的模型。

如果直接抛异常,整个 agent 就死了,用户只看到一堆栈。而如果把错误信息当作工具结果返回给模型,它会自己看懂并换个做法 ——比如看到「文件不存在」,它就会去列一下目录里有什么。

这里有个反直觉但很重要的判断:错误处理的责任转移了。传统程序里错误要由调用方处理;在 agent 里,错误是喂给模型的输入。所以工具永远返回字符串,从不抛异常。

发给模型的东西和给自己用的东西,是两份

API 的 tools 参数要求每个工具提供:名字、说明、以及一个 JSON Schema 描述参数。这份声明会被原样序列化进请求体,模型完全靠它来决定「我该怎么填参数」。

所以你的代码里要有两份表:

  • TOOLS —— 给模型看的声明。写得含糊,模型就会填错参数。
  • HANDLERS —— 给自己用的实现字典。

两份表必须同名。名字对不上,模型会看到一个「声明存在、但调用时必然失败」的工具 —— 这种 bug 很隐蔽,因为模型不会说「这个工具没实现」,它只会反复重试。

为什么文件操作不走 shell

一个很自然的想法是:读文件就执行 cat xxx,写文件就 echo ... > xxx。不要这么做,有三个原因:

  • 引号转义:文件名里有空格或中文括号,拼出来的命令就断了;内容里有引号更是灾难。
  • glob 展开:cat *.py 会被 shell 展开成一堆文件,而模型的本意可能就是一个叫 *.py 的文件。
  • 无法定位错误:shell 只给你一个返回码,你不知道是文件不存在还是权限不够。直接 open() 的话,OSError 会明确告诉你原因。

路径就是路径,内容就是内容。shell 只留给真正需要跑命令的场景(调用 git、跑测试)。

路径校验:模型会给出逃逸路径

模型可能给出 ../../etc/passwd,或者 C:\Windows\System32\...。这不是恶意,它只是在猜。但你的工具不能真的去读。

解法是把路径解析成绝对路径,再确认它落在工作目录的子树内。关键是必须用 os.path.realpath 而不是 abspath —— 前者会展开符号链接。否则一个指向外部的软链接就能绕过检查。

设计取舍

本章新增或修改

本章只新增 tools.py 一个文件,再让 agent.py 把「执行工具」改成调用统一入口 submit()。目标:给 agent 一双手 —— 它能读文件、改文件、跑命令,而主循环一行都不用改;要加一个新工具,也只需要动 tools.py。

新增tools.py上半部分是给模型看的 TOOLS 声明(OpenAI function schema),下半部分是给自己用的 HANDLERS 实现表和 submit() 入口,外加 safe_path() 把模型给的路径关进工作目录。
修改agent.py执行工具的那一段从「直接调用」改成 tools.submit(name, args) —— 循环从此不关心有哪些工具。

怎么实现

第 1 步:先写 TOOLS 声明
  • 查看 mini_agent/tools.py 的 _function 与 TOOLS,声明 bash、read、write、edit 四个工具。
  • schema 中的工具名需要与 HANDLERS 的键对应。

工具模块的导入(mini_agent/tools.py 第 8–12 行)

import os
import subprocess

from .config import BLOCKED_PATTERNS, CONFIG
from .mutation_queue import mutation_queue

read 的 parameters:三个键,各管一件事

{
  "type": "object",                      ← 整体是一个对象(= Python 的 dict)
  "properties": {                        ← 逐个字段:键 = 参数名,值 = 字段自己的 schema
    "path":   {"type": "string",  "description": "要读的文件路径"},
    "offset": {"type": "integer", "description": "从第几行开始读,1 起算"},
    "limit":  {"type": "integer", "description": "最多读几行"}
  },
  "required": ["path"]                   ← 只有 path 必填;offset / limit 没进这个数组 = 可选
}

文件头的依赖来自当前 tools.py,下面的 schema 示例用来解释参数结构。

TOOLS:发给模型看的工具声明mini_agent/tools.py:21–74点击展开关键代码
mini_agent/tools.py第 21–74 行
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
def _function(name, description, properties, required):
    """Build an OpenAI-style function tool schema."""
    return {
        "type": "function",
        "function": {
            "name": name,
            "description": description,
            "parameters": {
                "type": "object",
                "properties": properties,
                "required": required,
            },
        },
    }


TOOLS = [
    _function(
        "bash",
        "Run a shell command.",
        {"command": {"type": "string"}},
        ["command"],
    ),
    _function(
        "read",
        "Read a file's text content.",
        {
            "path": {"type": "string"},
            "offset": {"type": "integer", "description": "1-indexed start line"},
            "limit": {"type": "integer", "description": "max lines to read"},
        },
        ["path"],
    ),
    _function(
        "write",
        "Write content to a file (overwrites the whole file).",
        {
            "path": {"type": "string"},
            "content": {"type": "string"},
        },
        ["path", "content"],
    ),
    _function(
        "edit",
        "Replace old_string with new_string in a file. "
        "old_string must appear exactly once in the file.",
        {
            "path": {"type": "string"},
            "old_string": {"type": "string"},
            "new_string": {"type": "string"},
        },
        ["path", "old_string", "new_string"],
    ),
]
第 2 步:写 safe_path 做路径校验
  • safe_path 使用 realpath 解析路径,再检查目标是否在工作目录中。
  • 文件工具调用这个函数;shell 工具仍按当前用户权限执行命令。
safe_path:把模型给的路径关进工作目录mini_agent/tools.py:81–90点击展开关键代码
mini_agent/tools.py第 81–90 行
81
82
83
84
85
86
87
88
89
90
def safe_path(raw):
    """Resolve a path and reject targets outside the working directory.
    
    realpath also resolves symbolic links. Raise ValueError for an outside path.
    This check applies to file tools; it does not sandbox shell commands."""
    workdir = os.path.realpath(os.getcwd())
    target = os.path.realpath(os.path.join(workdir, raw or "."))
    if target != workdir and not target.startswith(workdir + os.sep):
        raise ValueError(f"path outside workdir: {raw}")
    return target
第 3 步:写 bash / read / write 三个工具,签名统一
  • 查看 tool_bash、tool_read、tool_write,入参都是字典,返回值都是文本。
  • write 已接入变更队列,第 3 章再看队列怎样协调同文件写入。

第 1 章的 config.py 里补这个键(read 要用)

CONFIG = {
    # ……第 1 章的键都留着,这里只加一行
    "max_read_lines": 2000,
}

tool_read 读的就是 CONFIG 里的这个键。第 1 章那版 config.py 里没有它,漏掉会在读文件时直接抛 KeyError。2000 的意思是「一次最多读 2000 行」,更长的文件让模型用 offset / limit 自己分页。

bash / read:签名统一mini_agent/tools.py:101–152点击展开关键代码
mini_agent/tools.py第 101–152 行
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
def tool_bash(inp):
    """Run a shell command with a literal blacklist, timeout, and output limit."""
    command = str(inp.get("command", ""))
    if any(pattern in command for pattern in BLOCKED_PATTERNS):
        return "Error: Dangerous command blocked"

    try:
        proc = subprocess.run(
            command,
            shell=True,
            cwd=os.getcwd(),
            capture_output=True,
            text=True,
            encoding="utf-8",
            errors="replace",
            timeout=CONFIG["bash_timeout_s"],
        )
    except subprocess.TimeoutExpired:
        return f"Error: command timed out after {CONFIG['bash_timeout_s']}s"

    output = f"{proc.stdout or ''}{proc.stderr or ''}".strip()
    if not output:
        return "(no output)"
    return output[: CONFIG["max_output_chars"]]


def tool_read(inp):
    """Read numbered UTF-8 text with offset/limit pagination, without a shell."""
    try:
        abs_path = safe_path(str(inp.get("path", "")))
    except ValueError as e:
        return f"Error: {e}"

    try:
        with open(abs_path, "r", encoding="utf-8", errors="replace") as f:
            lines = f.readlines()
    except OSError as e:
        return f"Error: {e}"

    offset = int(inp.get("offset") or 1)  # 1-indexed
    limit = int(inp.get("limit") or CONFIG["max_read_lines"])
    start = max(0, offset - 1)
    selected = lines[start : start + limit]

    # Include line numbers so the model can reference exact locations.
    numbered = "".join(
        f"{start + i + 1:>6}\t{line}" for i, line in enumerate(selected)
    )
    remaining = len(lines) - (start + len(selected))
    if remaining > 0:
        numbered += f"\n... ({remaining} more lines)"
    return numbered or "(empty file)"
tool_write:整文件覆盖mini_agent/tools.py:155–173点击展开关键代码
mini_agent/tools.py第 155–173 行
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
def tool_write(inp):
    """Overwrite a whole file under the shared per-path mutation lock."""
    path = str(inp.get("path", ""))
    content = str(inp.get("content", ""))
    try:
        abs_path = safe_path(path)
    except ValueError as e:
        return f"Error: {e}"

    def mutate():
        try:
            os.makedirs(os.path.dirname(abs_path) or ".", exist_ok=True)
            with open(abs_path, "w", encoding="utf-8", newline="") as f:
                f.write(content)
        except OSError as e:
            return f"Error: {e}"
        return f"Wrote {len(content)} bytes to {path}"

    return mutation_queue.run(abs_path, mutate)
第 4 步:edit 的关键是「必须唯一」
  • tool_edit 先读取内容,再检查 old_string 的匹配次数,只有唯一匹配才替换。
  • 读、检查和写回都放在 mutate 内,由 mutation_queue.run 调用。
tool_edit:唯一匹配后替换mini_agent/tools.py:176–217点击展开关键代码
mini_agent/tools.py第 176–217 行
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
def tool_edit(inp):
    """Replace one exact match under the shared per-path mutation lock.
    
    Read, count matches, and write within the same protected operation. Require
    exactly one old_string match to avoid changing an unintended occurrence."""
    path = str(inp.get("path", ""))
    old_string = str(inp.get("old_string", ""))
    new_string = str(inp.get("new_string", ""))

    if not old_string:
        return "Error: old_string cannot be empty"

    try:
        abs_path = safe_path(path)
    except ValueError as e:
        return f"Error: {e}"

    def mutate():
        try:
            with open(abs_path, "r", encoding="utf-8", errors="replace") as f:
                content = f.read()
        except OSError as e:
            return f"Error: {e}"

        count = content.count(old_string)
        if count == 0:
            return f"Error: old_string not found in {path}"
        if count > 1:
            return (
                f"Error: old_string appears {count} times in {path}, must be unique. "
                "Include more surrounding context to make it unique."
            )

        try:
            with open(abs_path, "w", encoding="utf-8", newline="") as f:
                f.write(content.replace(old_string, new_string, 1))
        except OSError as e:
            return f"Error: {e}"
        return f"Edited {path}"

    # Protect the entire read, match, and write operation.
    return mutation_queue.run(abs_path, mutate)
第 5 步:写 submit,把两张表接起来
  • submit(name, inp) 从 HANDLERS 找到处理函数;工具错误转换成文本返回。
  • mini_agent/agent.py 的 _run_one(call, signal, emit) 解析 arguments,再调用 toolbox.submit。

循环默认配置与工具表(mini_agent/agent.py 第 164–166 行)

    tools = toolbox.TOOLS if tools is None else tools
    emit = emit or default_emit
    signal = signal or Signal()

tools 未显式传入时使用 toolbox.TOOLS,与工具模块的声明保持一致。

HANDLERS + submit:把两张表接起来mini_agent/tools.py:226–246点击展开关键代码
mini_agent/tools.py第 226–246 行
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
HANDLERS = {
    "bash": tool_bash,
    "read": tool_read,
    "write": tool_write,
    "edit": tool_edit,
}


def submit(name, inp):
    """Dispatch a tool by name and return its result as text.
    
    Return errors to the model so it can correct a call instead of crashing
    the agent. Unexpected handler exceptions are also converted to result text."""
    handler = HANDLERS.get(name)
    if handler is None:
        known = ", ".join(sorted(HANDLERS))
        return f"Error: unknown tool '{name}'. Available tools: {known}"
    try:
        return handler(inp)
    except Exception as e:  # Convert unexpected handler failures into model-visible errors.
        return f"Error: {type(e).__name__}: {e}"
_run_one:解析参数并调度工具mini_agent/agent.py:86–119点击展开关键代码
mini_agent/agent.py第 86–119 行
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
def _run_one(call, signal, emit):
    """Parse one call's JSON arguments, execute it, and return its result.
    
    Convert cancellation and tool exceptions to result text so every recorded
    assistant tool call can still receive its matching tool message."""
    fn = call.get("function") or {}
    name = fn.get("name")
    raw_args = fn.get("arguments")
    if isinstance(raw_args, str):
        try:
            inp = json.loads(raw_args)
        except json.JSONDecodeError:
            inp = {}
    else:
        inp = raw_args or {}
    if not isinstance(inp, dict):
        inp = {}

    emit("tool_start", name=name, input=inp)

    try:
        signal.check()                      # Chapter 4: check cancellation before execution too.
        output = toolbox.submit(name, inp)
    except Aborted as e:
        # Convert cancellation to a tool result rather than propagating it here.
        # Every assistant tool call must have a corresponding tool message.
        # A missing result would make the next request invalid.
        output = f"Error: aborted ({e})"
    except Exception as e:                  # Convert unexpected tool failures to result text.
        output = f"Error: {type(e).__name__}: {e}"

    emit("tool_end", name=name, output=output)

    return {"tool_call_id": call.get("id"), "content": output}
第 3 章

变更队列

并发写文件不能乱
目标 让一轮里的多个工具并发执行(读 5 个文件不用等 5 次),同时保证同一个文件的「读-改-写」严格串行。

要解决的问题

edit#1: 读到 "A"  ────────────┐
edit#2: 读到 "A"        ─────┼── 两者都读到 A
edit#1: 写入 "A1"       ─────┘
edit#2: 写入 "A2"                  ← 覆盖了 edit#1

原理

前两章的工具是串行执行的:模型一轮里要读 5 个文件,就得等 5 次磁盘 IO。改成并发很自然 —— 但一改就出事。

丢失更新:并发写文件会丢东西

假设模型一轮里发出两个 edit,都改同一个文件:

edit#1: 读 "A"   ──────────────┐
edit#2:     读 "A" ──────┼── 两者都读到 A
edit#1: 写 "A1"  ──────┘
edit#2:     写 "A2"      ← 覆盖了 edit#1,它的改动消失了

这不是「谁后写谁赢」那么简单 —— edit#1 的改动彻底丢了,而且没有任何报错。模型以为两处都改了,实际只改了一处。这类 bug 叫「丢失更新」(lost update),是并发编程里最经典的一类问题。

注意 root cause 是「读-改-写」不是原子的。edit 不是一次写入,而是「读进来 → 算新内容 → 写回去」三步。两个 edit 交错执行,第二步就会基于过期的内容计算。

关键判断:先有并发,才需要队列

这个顺序容易被搞反。串行执行时根本不会有冲突,没有并发时通常不需要这层协调;这里的同一份实现已包含队列,第 3 章集中说明它的作用。

如果你先写队列再写并发,会得到一段「看起来在保护什么、但其实没有并发所以永远测不出问题」的代码 —— 那是很糟的状态:你不知道它到底对不对。

设计:按路径分桶,而不是一条全局锁

最省事的做法是加一把全局锁,所有写盘都排队。它能解决问题,但把并发的好处也一起抹掉了 —— 改 a.py 和改 b.py 明明互不相干,却要互相等待。

正确做法是按文件路径分桶:

  • 同一个文件 → 共用一把锁 → 串行。
  • 不同文件 → 各用各的锁 → 依然并行。

实现就是一个字典 {路径: 锁},用的时候 setdefault 取一把。相关实现集中在 FileMutationQueue 中,但它有三个容易写错的地方,下面「实现指导」逐一说明。

隐藏的第二层竞态:取锁这个动作本身

按路径分桶之后,还有一个更隐蔽的问题:「拿到那把锁」这个动作本身也要串行。看两行代码 lock = locks.get(key)、if lock is None: locks[key] = Lock() —— 这中间是有缝的:

线程A: get(key) → None  ──────┐
线程B:      get(key) → None ──┼── 两者都发现「还没有锁」
线程A: 建 A锁,存进去 ────────┘
线程B:     建 B锁,存进去    ← 覆盖了 A锁
结果:两个线程各拿一把不同的锁去改同一个文件

这就是为什么模块里有两把锁:一把 _registry 保护那个字典本身,一把 _locks[key] 保护某个文件。保护「锁」的锁,和锁本身,是两件事 —— 漏掉前者,队列等于不存在。

这类 bug 的特点是偶发:平时跑得好好的,压力一大才丢更新。所以它不能靠测试发现,只能靠推理写对。

顺带一提:真实项目 pi 里对应的 file-mutation-queue.ts 也只有 61 行。这类「少而关键」的并发原语,长度短但每行都有理由,值得逐行读懂。

设计取舍

本章新增或修改

本章新增 mutation_queue.py,改动 tools.py 的 write / edit 两处让写盘排队,并把 agent.py 的工具执行从串行改成线程池。目标:多个工具能并发跑得快,同时保证同一个文件的「读-改-写」不会互相覆盖。

新增mutation_queue.py按规范化后的绝对路径分桶加锁:同文件串行、不同文件并行;run(path, fn) 是一个文件变更的唯一入口。
修改tools.pywrite / edit 把整个「读 → 查重 → 写」序列包成一个闭包交给 mutation_queue.run() —— 排队粒度是整段,不是最后一次写盘。
修改agent.py工具执行改用 ThreadPoolExecutor,但结果必须按原调用顺序收集 —— 顺序错了模型就对不上号。

怎么实现

第 1 步:设计 key 的归一化(第一个坑)
  • 查看 FileMutationQueue._key_for,用规范化的绝对路径作为分桶键。
分桶 key:用 realpath 归一化mini_agent/mutation_queue.py:13–25点击展开关键代码
mini_agent/mutation_queue.py第 13–25 行
13
14
15
16
17
18
19
20
21
22
23
24
25
class FileMutationQueue:
    """Serialize file mutations using a lock per normalized absolute path."""

    def __init__(self):
        # Normalized path key -> lock.
        self._locks = {}
        # Protect the lock registry itself.
        self._registry = threading.Lock()

    @staticmethod
    def _key_for(path):
        """Normalize existing or new file paths with abspath and realpath."""
        return os.path.realpath(os.path.abspath(path))
第 2 步:写取锁逻辑(第二个坑)
  • _registry 保护锁表:同一个 key 共用同一把锁。
取锁这一步本身也要串行mini_agent/mutation_queue.py:29–36点击展开关键代码
mini_agent/mutation_queue.py第 29–36 行
29
30
31
32
33
34
35
36
        key = self._key_for(path)

        # Serialize lock creation so same-path callers share one lock.
        with self._registry:
            lock = self._locks.get(key)
            if lock is None:
                lock = threading.Lock()
                self._locks[key] = lock
第 3 步:写 run(),把操作包进锁里
  • run(path, fn) 取得目标文件的锁后调用 fn;不同 key 使用不同锁。
run():把操作包进锁里mini_agent/mutation_queue.py:27–40点击展开关键代码
mini_agent/mutation_queue.py第 27–40 行
27
28
29
30
31
32
33
34
35
36
37
38
39
40
    def run(self, path, fn):
        """Run the no-argument mutation fn under the target path's lock."""
        key = self._key_for(path)

        # Serialize lock creation so same-path callers share one lock.
        with self._registry:
            lock = self._locks.get(key)
            if lock is None:
                lock = threading.Lock()
                self._locks[key] = lock

        # Serialize operations per path; different paths use different locks.
        with lock:
            return fn()
第 4 步:改造 edit / write,让整个序列进临界区(第三个坑)
  • tool_edit 和 tool_write 都把实际变更动作交给 mutation_queue.run。
  • 临界区覆盖完整的读、检查、写回过程。
edit:整个「读 → 查重 → 写」序列进临界区mini_agent/tools.py:176–217点击展开关键代码
mini_agent/tools.py第 176–217 行
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
def tool_edit(inp):
    """Replace one exact match under the shared per-path mutation lock.
    
    Read, count matches, and write within the same protected operation. Require
    exactly one old_string match to avoid changing an unintended occurrence."""
    path = str(inp.get("path", ""))
    old_string = str(inp.get("old_string", ""))
    new_string = str(inp.get("new_string", ""))

    if not old_string:
        return "Error: old_string cannot be empty"

    try:
        abs_path = safe_path(path)
    except ValueError as e:
        return f"Error: {e}"

    def mutate():
        try:
            with open(abs_path, "r", encoding="utf-8", errors="replace") as f:
                content = f.read()
        except OSError as e:
            return f"Error: {e}"

        count = content.count(old_string)
        if count == 0:
            return f"Error: old_string not found in {path}"
        if count > 1:
            return (
                f"Error: old_string appears {count} times in {path}, must be unique. "
                "Include more surrounding context to make it unique."
            )

        try:
            with open(abs_path, "w", encoding="utf-8", newline="") as f:
                f.write(content.replace(old_string, new_string, 1))
        except OSError as e:
            return f"Error: {e}"
        return f"Edited {path}"

    # Protect the entire read, match, and write operation.
    return mutation_queue.run(abs_path, mutate)
第 5 步:把循环那边改成线程池
  • execute_tool_calls(calls, *, signal, emit, executor=None) 接收线程池;多个任务并发执行,按提交列表收集结果。
  • make_executor 创建线程池,CLI 初始化时创建,并在调用循环时传入。

创建线程池(mini_agent/agent.py 第 248–250 行)

def make_executor(max_workers=MAX_PARALLEL_TOOLS):
    """Create a tool thread pool that the CLI reuses for the session."""
    return ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix="tool")

执行并回灌工具结果(mini_agent/agent.py 第 241–243 行)

        results = execute_tool_calls(calls, signal=signal, emit=emit, executor=executor)
        for r in results:
            _append(messages, {"role": "tool", **r}, on_append)

CLI 初始化线程池与其他状态(mini_agent/cli.py 第 74–83 行)

    def __init__(self, args):
        self.args = args
        self.session = self._open_session()
        self.signal = Signal()
        self.steering = SteeringQueue()
        self.executor = agent_mod.make_executor()
        self.render = make_renderer(args.verbose)
        self.skills = prompt.load_skills()
        self.system_prompt = prompt.build_system_prompt(self.skills)
        self.before_request = self._make_before_request()

worker 将线程池传给循环(mini_agent/cli.py 第 142–154 行)

        def worker():
            try:
                agent_mod.agent_loop(
                    self.session.messages,
                    system=self.system_prompt,
                    signal=self.signal,
                    steering=self.steering,
                    max_turns=self.args.max_turns,
                    emit=self.render,
                    executor=self.executor,
                    on_append=self.session.persist,           # Chapter 5: persist only; the loop already appended in memory.
                    before_request=self.before_request,      # Chapter 6: compact before requests.
                )

交互结束时收尾(mini_agent/cli.py 第 262–263 行)

        self.executor.shutdown(wait=False)
        print('{0}Session saved: {1}{2}'.format(DIM, self.session.path, RESET))

这些都是当前实现的实际调用点,参数名与源文件保持一致。

execute_tool_calls:并发执行、顺序收集mini_agent/agent.py:69–83点击展开关键代码
mini_agent/agent.py第 69–83 行
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
def execute_tool_calls(calls, *, signal, emit, executor=None):
    """Execute tool calls and return result dictionaries in call order.
    
    Each result contains tool_call_id and content. The caller adds role='tool'.
    The executor allows concurrent work; results retain the original call order."""
    if not calls:
        return []

    # A single call or no executor: execute sequentially.
    if len(calls) == 1 or executor is None:
        return [_run_one(c, signal, emit) for c in calls]

    # Run concurrently, but collect results in the original call order.
    futures = [executor.submit(_run_one, c, signal, emit) for c in calls]
    return [f.result() for f in futures]
第 4 章

中断与转向

跑起来之后怎么插手
目标 agent 在跑的几十秒里,你既能 Ctrl+C 叫停,也能顺手补一句话改变它的方向。

要解决的问题

原理

agent 跑一轮可能要几十秒(模型思考慢 + 工具执行慢)。如果主线程傻等,你既没法叫停,也没法补话 —— 只能干看着它烧钱。

要解决这个,需要两样互不相同的东西:中断(我不想让你继续了)和转向(我想让你改个方向)。它们看起来像同一件事,但机制完全不同,所以要两个类。

中断:为什么用异常,而不是返回值

中断可能发生在很深的地方:正在 urlopen 等着 HTTP 响应、正在等待一个子进程结束、正在递归的某个工具函数里。

如果用返回值传播,每个函数的签名都要变成 -> tuple[result, aborted],每一层都要检查并向上传递。这会把代码污染得没法看,而且只要漏掉一层,中断就失效。

异常天生就是为「跨越很多层直接跳出去」设计的。所以中断用异常:在最外层捕获,在检查点抛出。

代价是异常会跳过正常的清理逻辑,所以检查点的位置要慎重选 —— 不能扔在「已经改了内存、还没写磁盘」这种中间状态上。

Signal 为什么用 threading.Event

第 4 章同时要把 agent 放到后台线程里跑(主线程才能继续读你的输入)。于是中断标志就变成了跨线程共享的可变状态。

一个普通的 self.flag = True 在这里是危险的:Python 有 GIL,简单赋值一般不会读到撕裂的值,但内存可见性和指令重排没有任何保证。threading.Event 内部有锁,是标准做法,也表达了「这是个同步点」的意图。

转向:为什么用队列,而不是一个变量

你可能在 agent 跑的时候连打两三句话。用单个变量存的话,后一句会覆盖前一句,前一句就丢了。所以用队列。

更关键的是取走的时机:请求已经发出去了,你这句话没法塞进当前这轮,只能等下一轮开始前插进历史。所以队列必须在循环的每轮开头被清空一次。

而「清空」这个动作必须是原子的:如果先 len() 判断再逐个 pop,中间主线程又 push 了一条,就可能漏掉或重复。所以要用「一次性取走并替换成空列表」的写法,而不是循环 pop。

最隐蔽的坑:孤儿 tool_call

这里需要注意工具调用与结果的配对,可以用测试检查。

时序是这样的:模型回复「我要调 read 工具」→ 循环把这个 assistant 消息原样存进历史(第 1 章的要求)→ 你在这个瞬间按了 Ctrl+C。

历史里现在是:
  assistant {tool_calls: [{id: "call_A", ...}]}   ← 存进去了
  (没有对应的 role="tool" 消息)                 ← 因为中断了,工具没跑

下次请求 → API 拒绝:
  Assistant message with tool_calls must be followed
  by tool messages for each tool_call_id

天真写法是在中断检查处直接 break。但那样历史里就留下了孤儿 tool_call —— 下次请求会被 API 整个拒绝,而且报错信息完全指不到真正的原因(用户会以为是模型或网络的问题)。

正确做法:中断时给每个没执行的调用补一条 role="tool" 消息,内容用 tool_call_id 对上、正文写 "Error: aborted",然后再退出。这样历史始终是合法的。

这个坑的本质是:第 1 章那条「每个 tool_call 必须有配对」的协议约束,在第 4 章引入中断之后才真正被违反。每加一个新功能,都要回头检查它是否破坏了之前的约束。

设计取舍

本章新增或修改

本章新增 control.py,在 agent.py 的循环里插四个检查点,在 llm.py 里插两次信号检查,最后把 cli.py 的 main() 升级成一个 App 类、让 agent 跑在后台线程里。目标:agent 在跑的那几十秒里,你随时能 Ctrl+C 叫停,也能顺手补一句话改变它的方向。

新增control.pyAborted 异常 + Signal(用 threading.Event 做的跨线程中断信号)+ SteeringQueue(转向消息队列,drain() 是「取走并清空」的原子动作)。
修改agent.py四个检查点:轮次开头、请求前、工具执行前后。中断时给被丢弃的每个工具调用补一条 role="tool" 消息,避免留下孤儿 tool_call。
修改llm.py发请求前查一次信号(省掉一个没人要的请求),收到响应后再查一次(等回来的这段时间你可能又按了中断)。
修改cli.py把 main() 拆成一个 App 类(__init__ 装状态、repl() 读输入、handle_command() 管 / 命令),主线程启动 worker 后立刻回到 input():打字进转向队列,Ctrl+C 调 signal.abort()。

怎么实现

第 1 步:写 control.py 的两个类
  • 查看 mini_agent/control.py 的 Signal 与 SteeringQueue,分别存储中断状态和补充输入。
中断信号:Aborted + Signalmini_agent/control.py:11–40点击展开关键代码
mini_agent/control.py第 11–40 行
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class Aborted(Exception):
    """Raise at cancellation checkpoints to unwind nested operations."""

    def __init__(self, reason="aborted"):
        super().__init__(reason)
        self.reason = reason


class Signal:
    """Share cancellation state across threads with threading.Event."""

    def __init__(self):
        self._event = threading.Event()
        self.reason = None

    def abort(self, reason="user aborted"):
        """Request cancellation; preserve the first recorded reason."""
        if not self._event.is_set():
            self.reason = reason
        self._event.set()

    @property
    def aborted(self):
        """Return whether cancellation has been requested, without raising."""
        return self._event.is_set()

    def check(self):
        """Raise Aborted if cancellation has been requested."""
        if self._event.is_set():
            raise Aborted(self.reason)
转向队列:SteeringQueuemini_agent/control.py:48–70点击展开关键代码
mini_agent/control.py第 48–70 行
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
class SteeringQueue:
    """Store follow-up instructions until the next model request.
    
    The main thread pushes input while the agent thread drains the queue.
    A list preserves multiple instructions instead of overwriting older input."""

    def __init__(self):
        self._items = []
        self._lock = threading.Lock()

    def push(self, text):
        """Enqueue one instruction from the input thread."""
        text = (text or "").strip()
        if not text:
            return
        with self._lock:
            self._items.append(text)

    def drain(self):
        """Atomically take all instructions and clear the queue."""
        with self._lock:
            items, self._items = self._items, []
        return items
第 2 步:在循环里加几个检查点
  • agent_loop 开头检查中断、读取转向队列并判断轮数上限。
  • assistant 已声明工具调用时,中断分支要补齐结果;_run_one 也会检查 signal。
循环开头的三个检查点mini_agent/agent.py:168–186点击展开关键代码
mini_agent/agent.py第 168–186 行
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
    turn = 0
    while True:
        # Step 1: cancellation check (chapter 4).
        if signal.aborted:
            emit("aborted", reason=signal.reason)
            break

        # Step 2: drain steering instructions (chapter 4).
        # Add follow-ups before asking the model so the next request includes them.
        if steering is not None:
            for text in steering.drain():
                _append(messages, {"role": "user", "content": text}, on_append)
                emit("steering", text=text)

        # Step 3: model-call limit (chapter 4).
        turn += 1
        if max_turns is not None and turn > max_turns:
            emit("max_turns", limit=max_turns)
            break
中断后的工具结果配对mini_agent/agent.py:215–236点击展开关键代码
mini_agent/agent.py第 215–236 行
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
        # Step 7b: check cancellation after the assistant declares tools.
        #
        # Do not simply break: the assistant call is already in history.
        # Each declared tool call must receive a matching result.
        # Otherwise the next request has invalid message ordering.
        # Providers may report an unmatched tool_call_id.
        #
        # Append an interrupted result for every pending call before exiting.
        # One call requires one matching tool message.
        if signal.aborted:
            for c in calls:
                _append(
                    messages,
                    {
                        "role": "tool",
                        "tool_call_id": c.get("id"),
                        "content": f"Error: aborted ({signal.reason})",
                    },
                    on_append,
                )
            emit("aborted", reason=signal.reason)
            break
已写入历史的工具调用需要配齐结果,才能继续请求模型。
工具执行处:中断必须被接住mini_agent/agent.py:104–119点击展开关键代码
mini_agent/agent.py第 104–119 行
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
    emit("tool_start", name=name, input=inp)

    try:
        signal.check()                      # Chapter 4: check cancellation before execution too.
        output = toolbox.submit(name, inp)
    except Aborted as e:
        # Convert cancellation to a tool result rather than propagating it here.
        # Every assistant tool call must have a corresponding tool message.
        # A missing result would make the next request invalid.
        output = f"Error: aborted ({e})"
    except Exception as e:                  # Convert unexpected tool failures to result text.
        output = f"Error: {type(e).__name__}: {e}"

    emit("tool_end", name=name, output=output)

    return {"tool_call_id": call.get("id"), "content": output}
第 3 步:在 llm.py 里加两次信号检查
  • call_llm 在发请求前和收到响应后检查 signal。
  • urllib 的阻塞读不能立即取消,因此中断不代表正在执行的请求或工具已经停止。
llm.py 里的第一次信号检查mini_agent/llm.py:31–38点击展开关键代码
mini_agent/llm.py第 31–38 行
31
32
33
34
35
36
37
38
    # Avoid sending a request after the user has already cancelled.
    if signal is not None:
        signal.check()

    # The protocol represents system instructions as a message.
    # Prepend it without changing the stored conversation history.
    system_text = SYSTEM_PROMPT if system is None else system
    full_messages = [{"role": "system", "content": system_text}] + messages
第二次检查:请求回来后再看一次mini_agent/llm.py:68–75点击展开关键代码
mini_agent/llm.py第 68–75 行
68
69
70
71
72
73
74
75
    # Chapter 4: check cancellation again after receiving the response.
    # Discard the response if cancellation occurred while waiting.
    # urllib's blocking read cannot be interrupted by this check.
    # Immediate transport cancellation would require a different request mechanism.
    if signal is not None:
        signal.check()

    return data
第 4 步:查看 CLI 的后台执行与输入
  • App.__init__ 保存会话、信号、队列、线程池和渲染器;App.repl 读取普通输入与命令。
  • App.run_turn 启动后台 worker,主线程继续读取补充输入。
  • 下面直接展示当前实现;相关会话和压缩接线在后面的章节说明。

App 初始化状态(mini_agent/cli.py 第 74–83 行)

    def __init__(self, args):
        self.args = args
        self.session = self._open_session()
        self.signal = Signal()
        self.steering = SteeringQueue()
        self.executor = agent_mod.make_executor()
        self.render = make_renderer(args.verbose)
        self.skills = prompt.load_skills()
        self.system_prompt = prompt.build_system_prompt(self.skills)
        self.before_request = self._make_before_request()

这里也包含第 5–7 章说明的会话、压缩与提示词接线。

App.repl:交互入口mini_agent/cli.py:239–263点击展开关键代码
mini_agent/cli.py第 239–263 行
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
    def repl(self):
        print('{0}mini_agent{1} {2}(/help for commands, Ctrl+C to interrupt, /exit to quit){3}'.format(BOLD, RESET, DIM, RESET))
        print('{0}Working directory: {1}{2}'.format(DIM, os.getcwd(), RESET))
        print('{0}Model: {1}   Session: {2}{3}'.format(DIM, CONFIG['model'], self.session.id, RESET))

        while True:
            try:
                user_text = input('\n{0}{1}You >{2} '.format(BOLD, CYAN, RESET)).strip()
            except (EOFError, KeyboardInterrupt):
                print()
                break

            if not user_text:
                continue
            if user_text.startswith("/"):
                try:
                    self.handle_command(user_text)
                except SystemExit:
                    break
                continue

            self.run_turn(user_text)

        self.executor.shutdown(wait=False)
        print('{0}Session saved: {1}{2}'.format(DIM, self.session.path, RESET))
App.run_turn:后台执行与输入mini_agent/cli.py:135–183点击展开关键代码
mini_agent/cli.py第 135–183 行
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
    def run_turn(self, user_text):
        """Run user input in a background thread while accepting follow-ups."""
        self.signal.reset()
        self.session.append("user", user_text)

        done = threading.Event()

        def worker():
            try:
                agent_mod.agent_loop(
                    self.session.messages,
                    system=self.system_prompt,
                    signal=self.signal,
                    steering=self.steering,
                    max_turns=self.args.max_turns,
                    emit=self.render,
                    executor=self.executor,
                    on_append=self.session.persist,           # Chapter 5: persist only; the loop already appended in memory.
                    before_request=self.before_request,      # Chapter 6: compact before requests.
                )
            except Exception as e:
                print('{0}[Error] {1}: {2}{3}'.format(RED, type(e).__name__, e, RESET))
            finally:
                done.set()

        thread = threading.Thread(target=worker, daemon=True, name="agent")
        thread.start()

        # Keep reading input while the background agent runs.
        while not done.is_set():
            try:
                line = input('{0}(Running… add instructions, Ctrl+C to interrupt){1} '.format(DIM, RESET)).strip()
            except EOFError:
                self.signal.abort("stdin closed")
                break
            except KeyboardInterrupt:
                print('\n{0}Interrupting…{1}'.format(YELLOW, RESET))
                self.signal.abort("user pressed Ctrl+C")
                break

            if not line:
                continue
            if line.startswith("/"):
                self.handle_command(line)          # Handle slash commands immediately.
            else:
                self.steering.push(line)           # Queue other input for the next model request.
                print('{0}  ↳ Queued for the next model call{1}'.format(YELLOW, RESET))

        thread.join(timeout=1)
第 5 章

会话持久化

关掉还能接着聊
目标 对话落到磁盘上,--continue 能恢复上次那一段。

要解决的问题

原理

前四章的 agent 一关就忘。而上下文恰恰是 agent 最贵的东西 ——里面有你花掉的 token、agent 读过的文件、它做过的决策。丢掉它等于把之前所有的钱和时间都浪费了。

格式选择:JSONL,而不是一个 JSON 数组

最直觉的做法是把整个 messages 列表序列化成一个 .json 文件。但每追加一条消息,都要:读出整个文件 → 解析 → push → 再序列化 → 整份覆盖写。

改成 JSONL(一行一条 JSON)之后,追加就只是 f.write(line + "\n")。三个理由,按重要性排序:

  • 追加便宜:一行一条,O(1) 而不是 O(文件大小)。聊到几百轮时这个差距很可观。
  • 崩溃安全:写到第 7 行时断电,前 6 行仍然是完整可解析的。整个 JSON 文件损坏的话,一个字节都读不出来。
  • 能容忍局部损坏:单行坏掉跳过它就好,不影响其他行。真实世界的日志文件里总会有半行。

这个思路有个名字叫事件日志(event log):只追加、不修改,当前状态由重放日志算出来。数据库的 WAL、Git 的 commit 历史 都是同一个套路。

并发写盘:为什么先写临时文件再改名

有一种操作没法用「追加」表达:整体重写。第 6 章的压缩会把前 N 条换成 1 条摘要 —— 这不是追加,是替换。

如果直接打开原文件覆盖写,写到一半崩溃就直接毁掉整个会话。所以要先写 xxx.jsonl.tmp,写完再 os.replace(tmp, path)。

os.replace 在 Windows 和 POSIX 上都是原子的:要么看到旧文件,要么看到新文件,不存在中间状态。这是保证「崩溃时数据不损坏」的标准手法。

最容易写错的地方:append 与 persist 的区别

落盘这件事只有两个动作:改内存(把消息加进对话列表)和写磁盘(往这一行的 JSONL 后面追加)。第 5 章把这两个动作拆给了两个对象 —— 循环改内存,session 写磁盘。钩子 on_append 就是这条契约的接口:一个动作只能有一个负责人,接错方法就会让某个动作被做两遍。

先看清钩子的调用时机。它就在循环里那层 _append 间接里,共四行:

def _append(messages, msg, on_append):
    messages.append(msg)          ← ① 循环自己已经把消息放进内存了
    if on_append: on_append(msg)  ← ② 钩子只是「通知」:我改完内存了

关键在 ②:钩子被调用时,那条消息已经在内存里了。所以钩子唯一该做的事就是「把它写进磁盘」—— 这正是 persist 的语义(只写磁盘,不碰内存)。如果接成 append_message(它的语义是「新增一条」:内存 + 磁盘都动),同一个动作就被做了两遍:

循环:  messages.append(msg)              ← 循环加第一遍
       → on_append(msg)
       → session.append_message(msg)
         → self.messages.append(msg)    ← session 又加第二遍(重复!)
           self._write_line(msg)        ← 磁盘只写了一行

跟着走一轮就看得见后果。假设这一轮是「用户一句 + 模型一次工具调用 + 一条工具结果」,循环跑到工具结果落盘为止:

               内存                       磁盘
user           1 条                       1 行  (CLI 走 session.append,同起点)
assistant      2 条 ← 重复了一份          1 行
tool           2 条 ← 重复了一份          1 行
               ─────                      ─────
               5 条                       3 行  ← 从这里开始两边对不上

注意哪边才是错的:磁盘那 3 行是对的,多出来的是内存。所以它不会在写盘的瞬间炸出来 —— 磁盘并没有少写东西。代价在模型那一侧:下一轮请求会把这段「自己重复自己」的历史发出去,assistant 的 tool_calls 和配对的 tool 消息各出现两次,配对关系被写坏。严格的 API 会直接拒绝(报错大意是「assistant 的 tool_calls 后面必须紧跟配对的 tool 消息」);宽松一点的照跑,但上下文被污染、白烧 token。换成 persist 就是内存 3 条 = 磁盘 3 行,永远同步。

反方向也接得错:干脆忘了接钩子(on_append 不传,或传了个什么都不做的函数)。这时内存是对的、磁盘只剩 CLI 自己写的那条用户消息(实测:内存 3 条、磁盘 1 行),--continue 回来就只剩你问的那句话。所以「它怎么忘了刚才说的话」这个症状主要来自这一种;而接成 append_message 的直接后果是内存里的历史被写坏。两种错法方向相反,但都会让两边不一致 —— 这就是为什么要靠契约,而不是靠「看起来对」。

三个方法的分工记牢,选错一步就掉进上面的坑:

  • session.append(role, content) —— 「我要新增一条」:内存 + 磁盘都动。CLI 把用户输入存进会话时用它。
  • session.append_message(msg) —— 同上,只是收完整 dict(从磁盘恢复历史时用)。不能当钩子:它的 docstring 里就写明了这句。
  • session.persist(msg) —— 「这条已经在内存里了,我只负责写盘」:只碰磁盘。钩子只能用这个。

根因一句话:两个对象都以为自己该改内存。解法是把职责写死成一条契约 —— 循环负责改内存,session 只负责落盘,谁也不重复对方的活。落到 CLI 上就是那一行 on_append=self.session.persist。

这个错误可能不会立即报错,却会悄悄破坏历史。关键不变量很简单:一轮完成后,内存中的消息与磁盘上的 JSONL 记录应描述同一份历史。

另一个 Python 坑:列表身份

还有一个类似的陷阱。replace_messages 要整体替换历史,直觉写法是 self.messages = messages。也是错的。

因为 agent_loop 和 CLI 都持有原来那个 list 对象。你让 self.messages 指向一个新列表,旧列表仍然被它们拿着 ——之后的 append 会写进一个已经没人看的列表,内存与磁盘从此永久脱钩。

正确写法是 self.messages[:] = messages —— 切片赋值是就地修改,所有持有者都能看到新内容。这个区别在 Python 里非常常见,值得单独记住:= 换对象,[:]= 换内容。

设计取舍

本章新增或修改

本章新增 session.py,在 agent.py 里加一个 _append 间接层加 on_append 钩子,并在 cli.py 里接上会话。目标:对话落到磁盘上,关掉终端之后还能 --continue 接着聊。顺带留出「整体重写历史」的入口,以后要整段换掉历史时有地方下手。

新增session.py一段会话 = 一个 JSONL 文件。六个方法:create / load / append / append_message / persist / replace_messages,外加 list_sessions() 和 latest_session()。
修改agent.py把散落的历史追加收进 _append(),并新增 on_append 钩子 —— 循环只负责改内存,落盘交给 session。
修改cli.py启动 / /new / /resume / /list 都走 session;用户输入先落盘再发给模型,请求失败也不会把你说的话弄丢。

怎么实现

第 1 步:设计 Session 类的基本形状
  • 查看 Session 的 id、messages、path,以及 create 如何确定会话文件。
Session 的基本形状mini_agent/session.py:17–34点击展开关键代码
mini_agent/session.py第 17–34 行
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Session:
    """A conversation with an in-memory message list and a JSONL file."""

    def __init__(self, session_id, messages, path):
        self.id = session_id
        self.messages = messages      # The same message list used by agent_loop.
        self.path = path

    # Construction.

    @classmethod
    def create(cls, session_id=None, session_dir=None):
        """Create a session, generating an ID when none is provided."""
        session_id = session_id or time.strftime("%Y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:6]
        directory = session_dir or CONFIG["session_dir"]
        os.makedirs(directory, exist_ok=True)
        path = os.path.join(directory, f"{session_id}.jsonl")
        return cls(session_id, [], path)
第 2 步:查看会话读取
  • Session.load 逐行读取 JSONL,当前基础实现遇到无法解析的行会跳过并提示。
  • 这里只说明现有实现,不把逐行可解析当成自动修复或完整崩溃恢复保证。
load:坏行直接跳过mini_agent/session.py:36–55点击展开关键代码
mini_agent/session.py第 36–55 行
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
    @classmethod
    def load(cls, path):
        """Reload JSONL messages, warning and skipping lines with invalid JSON."""
        if not os.path.exists(path):
            raise FileNotFoundError('Session file not found: {0}'.format(path))
        messages = []
        skipped = 0
        with open(path, "r", encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if not line:
                    continue
                try:
                    messages.append(json.loads(line))
                except json.JSONDecodeError:
                    skipped += 1
        session_id = os.path.splitext(os.path.basename(path))[0]
        if skipped:
            print('[Warning] Skipped {0} corrupt lines'.format(skipped))
        return cls(session_id, messages, path)
第 3 步:写三个写入入口,职责分清
  • append 与 append_message 同时修改内存和写盘;persist 只写盘。
  • 循环已追加消息时,on_append 应使用 persist,避免重复追加。
三个写入入口,别搞混mini_agent/session.py:59–80点击展开关键代码
mini_agent/session.py第 59–80 行
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
    def append(self, role, content):
        """Append a role/content message to memory and disk, then return it."""
        msg = {"role": role, "content": content}
        self.messages.append(msg)
        self._write_line(msg)
        return msg

    def append_message(self, msg):
        """Append a complete message to memory and disk, then return it.
        
        Do not use this as the loop's on_append hook: the loop has already appended
        the message, so doing it here would duplicate the in-memory entry."""
        self.messages.append(msg)
        self._write_line(msg)
        return msg

    def persist(self, msg):
        """Save a message that the loop has already appended to memory.
        
        Use this as on_append: the loop changes memory; this method writes to disk."""
        self._write_line(msg)
        return msg
第 4 步:写 replace_messages,注意两个细节
  • replace_messages 用切片替换保持列表对象不变,再通过临时文件重写历史。
Session.replace_messages:整体替换mini_agent/session.py:93–106点击展开关键代码
mini_agent/session.py第 93–106 行
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
        # The loop and caller still hold references to this list object.
        # Rebinding would leave later appends on an outdated list.
        # Slice assignment keeps memory and persistence connected.
        self.messages[:] = messages
        tmp = self.path + ".tmp"
        with open(tmp, "w", encoding="utf-8") as f:
            for msg in messages:
                f.write(json.dumps(msg, ensure_ascii=False) + "\n")
        os.replace(tmp, self.path)      # Replace the file atomically after writing the temporary copy.

    # Session inventory.

    def stats(self):
        return {
切片赋值保留原列表对象,避免其他地方仍引用旧列表。
第 5 步:接到循环和 CLI 上
  • mini_agent/agent.py 的 _append 负责通知持久化钩子;App.run_turn 传入 session.persist。
  • App._open_session 处理恢复参数,App.handle_command 中的 /new、/list、/resume 管理会话。
_append:追加消息与持久化钩子mini_agent/agent.py:133–137点击展开关键代码
mini_agent/agent.py第 133–137 行
133
134
135
136
137
def _append(messages, msg, on_append):
    messages.append(msg)
    if on_append is not None:
        on_append(msg)
    return msg
循环已经追加消息,持久化钩子只写盘,避免重复追加。
两个小工具:列出所有会话 / 找最近那个mini_agent/session.py:119–131点击展开关键代码
mini_agent/session.py第 119–131 行
119
120
121
122
123
124
125
126
127
128
129
130
131
    files = [
        os.path.join(directory, n)
        for n in os.listdir(directory)
        if n.endswith(".jsonl")
    ]
    files.sort(key=lambda p: os.path.getmtime(p), reverse=True)
    return files


def latest_session(session_dir=None):
    """Return the most recently modified session path, or None."""
    files = list_sessions(session_dir)
    return files[0] if files else None
App 的会话初始化与查找mini_agent/cli.py:74–114点击展开关键代码
mini_agent/cli.py第 74–114 行
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
    def __init__(self, args):
        self.args = args
        self.session = self._open_session()
        self.signal = Signal()
        self.steering = SteeringQueue()
        self.executor = agent_mod.make_executor()
        self.render = make_renderer(args.verbose)
        self.skills = prompt.load_skills()
        self.system_prompt = prompt.build_system_prompt(self.skills)
        self.before_request = self._make_before_request()

    # Assembly.

    def _open_session(self):
        """Start a new session, continue the latest, or resume a selected ID."""
        if self.args.resume:
            path = self._find_session_path(self.args.resume)
            loaded = session_mod.Session.load(path)
            print('{0}Resumed session {1} ({2} messages){3}'.format(DIM, loaded.id, len(loaded.messages), RESET))
            return loaded

        if self.args.continue_:
            path = session_mod.latest_session()
            if path:
                loaded = session_mod.Session.load(path)
                print('{0}Continuing session {1} ({2} messages){3}'.format(DIM, loaded.id, len(loaded.messages), RESET))
                return loaded
            print('{0}No previous session; starting a new one{1}'.format(DIM, RESET))

        return session_mod.Session.create()

    def _find_session_path(self, session_id):
        directory = CONFIG["session_dir"]
        path = os.path.join(directory, f"{session_id}.jsonl")
        if os.path.exists(path):
            return path
        # Allow a session ID prefix as a convenience.
        for candidate in session_mod.list_sessions(directory):
            if os.path.basename(candidate).startswith(session_id):
                return candidate
        raise SystemExit('Session not found: {0} (use /list to see available sessions)'.format(session_id))
App.run_turn:接入会话mini_agent/cli.py:135–158点击展开关键代码
mini_agent/cli.py第 135–158 行
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
    def run_turn(self, user_text):
        """Run user input in a background thread while accepting follow-ups."""
        self.signal.reset()
        self.session.append("user", user_text)

        done = threading.Event()

        def worker():
            try:
                agent_mod.agent_loop(
                    self.session.messages,
                    system=self.system_prompt,
                    signal=self.signal,
                    steering=self.steering,
                    max_turns=self.args.max_turns,
                    emit=self.render,
                    executor=self.executor,
                    on_append=self.session.persist,           # Chapter 5: persist only; the loop already appended in memory.
                    before_request=self.before_request,      # Chapter 6: compact before requests.
                )
            except Exception as e:
                print('{0}[Error] {1}: {2}{3}'.format(RED, type(e).__name__, e, RESET))
            finally:
                done.set()
三条会话命令:/new、/list、/resumemini_agent/cli.py:187–215点击展开关键代码
mini_agent/cli.py第 187–215 行
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
    def handle_command(self, line):
        parts = line.split(maxsplit=1)
        cmd = parts[0].lower()
        arg = parts[1].strip() if len(parts) > 1 else ""

        if cmd in ("/exit", "/quit", "/q"):
            raise SystemExit(0)
        if cmd == "/help":
            print(HELP_TEXT)
        elif cmd == "/new":
            self.session = session_mod.Session.create()
            print('{0}New session: {1}{2}'.format(DIM, self.session.id, RESET))
        elif cmd == "/list":
            files = session_mod.list_sessions()
            if not files:
                print('(No sessions yet)')
            for path in files[:20]:
                name = os.path.basename(path)
                size = os.path.getsize(path)
                print(f"  {name}  ({size} bytes)")
        elif cmd == "/resume":
            if not arg:
                print('Usage: /resume <session-id>')
            else:
                try:
                    self.session = session_mod.Session.load(self._find_session_path(arg))
                    print('{0}Switched to {1}{2}'.format(DIM, self.session.id, RESET))
                except SystemExit as e:
                    print(e)
第 6 章

上下文压缩

历史太长怎么办
目标 当估算的 token 逼近模型窗口时,把最老的一段历史压成一段摘要,用摘要替换原文。

要解决的问题

原理

第 1 章算过那笔账:历史只增不减,而每轮都要把全部历史发出去。成本按 N²/2 涨,迟早撞上模型的上下文窗口,API 直接报错。

压缩的思路很直接:把最老的一段历史交给模型,让它写成一段摘要,然后用摘要替换原文。复杂度从 O(N) 降到 O(1)。

核心难点:切点必须落在「整轮」的边界上

压缩要保留最近几轮的原话(细节重要),只压老的。那切点该切在哪?直觉是「切在中间某条消息之前」,但这样会出事:

如果你切在这里 ↓
  assistant {tool_calls:[{id: "call_A"}]}   ← 被压掉了
  tool      {tool_call_id: "call_A", ...}   ← 保留下来

历史里就出现了一条没有 assistant 配对 tool_calls 的 role="tool" 消息 → API 拒绝

又是第 1 章那条协议约束。所以切点必须落在一轮的开头。

那「一轮」怎么定义?一轮 = 一条 user 提问 + 它引发的所有 assistant 工具调用 + 对应的 role="tool" 结果 + 收尾的 assistant 文字。而判据要看仔细 —— OpenAI 里工具结果是独立的 role="tool" 消息,不是 user:

{"role": "user",      "content": "帮我改一下 config"}       ← 新一轮开头 ✓
{"role": "assistant", "content": ..., "tool_calls": [{...}]}
{"role": "tool",       "tool_call_id": "call_A", ...}         ← 独立的 tool 角色
{"role": "assistant", "content": "改好了"}

好在判据依然很简单:新提问是 role == "user" 且 content 是纯字符串。工具结果是 role="tool",天然不会被当成一轮的开头 —— 所以判据 role == "user" and isinstance(content, str) 干净可靠,那层「靠 content 类型区分」的麻烦在 OpenAI 协议里已经不存在了。

这个 is_turn_start 是全部压缩逻辑的基础 —— 写错了不会报错,只会切在不该切的地方,然后在下次请求时炸掉。

保留最近几轮:留几轮、怎么定

切点定下来,还剩一个参数要定:保留多少。留得少省得多,但留在上下文里的原文也少 —— 这一节讲这个数怎么取舍。

教程版的做法最省事:把「保留几轮」写成一个常数 —— 配置里的 compaction_keep_recent_turns(默认 3)。find_cut_index 先把历史里所有「轮的开头」收集起来,从后往前数到第 3 个,切在它前面:

turn_starts = [0, 5, 11, 18, 24]        # 历史里 5 个「轮的开头」
keep = 3
cut = turn_starts[len(turn_starts) - keep] = turn_starts[2] = 11

  messages[:11] 拿去写摘要,messages[11:] 原样保留
  轮数不够(len(turn_starts) <= keep)→ 返回 None:没什么可压的

为什么是 3? 没有推导,是拍的 —— 够装下「正在做的那件事」就行。真正值得讲的是这个数该按什么取舍。

先看一个容易走偏的直觉:既然目的是别超窗口,那「压到刚好低于阈值」不是最省吗?不是。因为压缩不是一次性动作,而是会反复发生的 —— 只压到贴着阈值,下一轮的回答加工具输出立刻把体积推回去,于是每轮都要再压一次;摘要叠摘要,上下文很快退化成一层层转述。

所以真正要问的不是「压多少才不超窗口」,而是「压完之后还能自由地跑几轮」。这就是「保留最近几轮」的实质:留出一段完整轮的原文当工作区,让接下来几轮都不必立刻再压。

那用什么单位量这段工作区?三种单位都有真实产品在用,取舍不同:

  • 按条数(保留最近 N 条消息):实现最简单,但一轮的条数是浮动的 —— 一次带工具的问答可能 2 条,也可能 30 条,实际保留量能差几十倍。
  • 按轮数(保留最近 N 轮):保留量随「轮的大小」波动,但轮是用户能感知的单位,翻日志、讲给别人听都说得清(「最近 3 次提问还在原文里」)。教程版走这条。
  • 按 token 预算(留够约 M 个 token 的最近历史):最贴近真实约束,实际保留的轮数会自动适应 —— 聊得啰嗦就少留几轮,聊得紧凑就多留几轮;代价是要能算 token,而且切点可能落在轮中间,得额外处理「被切开的半轮」。

不管用哪种单位,三条原则是共同的:

  • 只压老的,不碰新的:正在做的事、刚才那次工具调用的原始输出,都躺在最近几轮里;摘要一定会丢细节,所以这段必须留原文。
  • 保留的单位要是完整的轮:留半轮等于制造一条配不上对的 tool 消息(上一节那条约束)。
  • 判据是「工作区够不够用」,不是「token 数够不够低」:标准是 —— 模型接着干活时,要不要回头向你要原始信息。

两边的真实实现都在下面,点开看源码和提取出来的字符串 —— 它们的取舍比教程版讲究,但也更复杂。

pi:按 token 预算从末尾回溯(默认留 20000 token)点击展开原文
// 1) 默认值:packages/coding-agent/src/core/settings-manager.ts:18-28
const DEFAULT_COMPACTION_TOKEN_SETTINGS: Required<CompactionModelOverride> = {
	reserveTokens: 16384,
	keepRecentTokens: 20000,
};

export interface CompactionSettings {
	enabled?: boolean; // default: true
	reserveTokens?: number; // default: 16384
	keepRecentTokens?: number; // default: 20000
	modelOverrides?: Record<string, CompactionModelOverride>; // exact "provider/modelId" keys
}

// 2) 什么时候压:packages/agent/src/harness/compaction/compaction.ts:245-249
/** Return whether context usage exceeds the configured compaction threshold. */
export function shouldCompact(contextTokens: number, contextWindow: number, settings: CompactionSettings): boolean {
	if (!settings.enabled) return false;
	return contextTokens > contextWindow - settings.reserveTokens;
}

// 3) 切点长什么样:compaction.ts:359-367 —— 它允许「切开一轮」
/** Cut point selected for compaction. */
export interface CutPointResult {
	/** Index of the first entry retained after compaction. */
	firstKeptEntryIndex: number;
	/** Index of the turn-start entry when the cut splits a turn, otherwise -1. */
	turnStartIndex: number;
	/** Whether the selected cut point splits an in-progress turn. */
	isSplitTurn: boolean;
}

// 4) 留多少:compaction.ts:369-418
//    从末尾往前累加「估算 token」,一旦达到 keepRecentTokens,
//    就选「不小于当前下标」的第一个合法切点
/** Find the compaction cut point that keeps approximately the requested recent-token budget. */
export function findCutPoint(
	entries: Entry[],
	startIndex: number,
	endIndex: number,
	keepRecentTokens: number,
): CutPointResult {
	const cutPoints = findValidCutPoints(entries, startIndex, endIndex);

	if (cutPoints.length === 0) {
		return { firstKeptEntryIndex: startIndex, turnStartIndex: -1, isSplitTurn: false };
	}
	let accumulatedTokens = 0;
	let cutIndex = cutPoints[0];

	for (let i = endIndex - 1; i >= startIndex; i--) {
		const entry = entries[i];
		if (entry.type !== "message") continue;
		const messageTokens = estimateTokens(entry.message as AgentMessage);
		accumulatedTokens += messageTokens;
		if (accumulatedTokens >= keepRecentTokens) {
			for (let c = 0; c < cutPoints.length; c++) {
				if (cutPoints[c] >= i) {
					cutIndex = cutPoints[c];
					break;
				}
			}
			break;
		}
	}
	while (cutIndex > startIndex) {
		const prevEntry = entries[cutIndex - 1];
		if (prevEntry.type === "compaction") {
			break;
		}
		if (prevEntry.type === "message") {
			break;
		}
		cutIndex--;
	}
	const cutEntry = entries[cutIndex];
	const isUserMessage = cutEntry.type === "message" && cutEntry.message.role === "user";
	const turnStartIndex = isUserMessage ? -1 : findTurnStartIndex(entries, cutIndex, startIndex);

	return {
		firstKeptEntryIndex: cutIndex,
		turnStartIndex,
		isSplitTurn: !isUserMessage && turnStartIndex !== -1,
	};
}
Codex(0.156.0):整段换成交接摘要,不留最近几轮原文点击展开原文
// 配置里能调的旋钮(同处出现的源码路径:core/src/compact.rs、core/src/compact_token_budget.rs)
model_context_window
model_auto_compact_token_limit
model_auto_compact_token_limit_scope
// 上一条的取值:total | body_after_prefix
model_post_turn_compact_threshold_percent
compact_prompt

// 摘要注入新窗口时,套在最前面的固定前缀(全文)
Another language model started to solve this problem and produced a summary of its thinking process. You also have access to the state of the tools that were used by that language model. Use this to build on the work that has already been done and avoid duplicating work. Here is the summary produced by the other language model, use the information in this summary to assist with your own analysis:

// 压完仍然超窗口时,逐条丢掉最老的历史项(原文这句以 "Error: " 加一个空格结尾,后面接具体报错)
Context window exceeded while compacting; removing oldest history item. Error:

// 多次压缩后的提醒,以及拿不到摘要时的占位
Heads up: Long threads and multiple compactions can cause the model to be less accurate. Start a new thread when possible to keep threads small and targeted.
Post-turn compaction completed without an assistant summary
(no summary available)

// 窗口彻底耗尽前的兜底提示(TokenBudgetConfigToml.auto_compact_fallback_prompt,全文)
<context_window_reminder>
The current context window is exhausted. Do not continue the task or give a final answer in this window. The next window will not automatically include this conversation. Make exactly one write or append call to `notes` now to save a concise checkpoint with the goal, decisions, progress, learnings, next steps, and the window ID and item ID of every relevant user request still being solved, as well as important actions/tool calls for future reference. Note that every non-assistant item, such as user, developer, tool response, has an item id `[id: ...]` that is immediately after its item content. After the notes result returns, call `functions.new_context`; do not use any tools other than `notes` and `functions.new_context`.
</context_window_reminder>

// 配套默认值
"auto_compact_fallback_buffer_tokens": 16384

// 常驻提示 guidance_message 的头两段(后面还有一段讲 context reset,此处略)
For tasks that may span context windows, use `notes` to maintain a concise checkpoint of the goal, decisions, progress, learnings and next steps. Include the window ID and item ID for every relevant user request you are currently solving as well as important actions/tool calls. You can use `history` tool to look up details with the references later. Note that every non-assistant item, such as user, developer, tool response, has an item id `[id: ...]` that is immediately after its item content. Relative note paths belong to the current thread; absolute paths may read other threads' notes, but writes are limited to the current thread.

It is a good idea to take incremental notes while you work so that you do not miss any important info. You can also use `get_context_remaining` tool to find the remaining token budget for better planning. Once the token budget is exhausted, you will lose access to the current window and continue in a fresh context window and you can only recover through `notes` and `history` tools. So be careful not to over-run the context window without any documentation.

三种路线摆在一起:教程版固定轮数,判据最粗,但一眼能懂、好改;pi 按 token 预算从末尾回溯,保留量自适应,代价是要处理「切在轮中间」的半轮;Codex 干脆不留原文 —— 整段历史换成一份交接摘要,注入时前面套一段固定前缀(「另一个语言模型已经解过这个问题……用这份摘要辅助你自己的分析」),压完仍超窗口就逐条丢掉最老的历史项,并在窗口彻底耗尽前要求模型先把 checkpoint 写进 notes 工具(因为下一个窗口不会自动带上这段对话)。

一句提醒:压缩终究是「丢信息」的操作,只是丢得聪明。跨很多轮才用得到的约束、路径、结论,最好在它出现的那一轮就落到项目里的文件(AGENTS.md、笔记、测试),不要指望摘要替你记住。

token 估算:三条都要数到

要精确知道有多少 token,得用对应模型的 tokenizer 逐字计算。那意味着引入依赖、而且换模型就要换 tokenizer。

这里用 字符数 / 3 就够了 —— 对中英文混排是个粗糙但可用的近似。关键是要留足余量:估多了顶多提前压一次(浪费一点钱),估少了就会真的超窗口然后请求失败。

但「字符数」只算了 content。一个会漏算的地方:assistant 的 tool_calls(每个调用的函数名 + arguments)以及 role="tool" 消息的 tool_call_id,它们同样要占 token。漏了它们,history 的实际容量会被低估,压缩时机就会被推迟。

所以配置里要有一个 compaction_reserve_tokens(预留量),触发条件是 估算值 > 上限 - 预留量,而不是 > 上限。

摘要的质量由提示词决定

如果只说「总结一下这段对话」,模型很可能写成给人看的散文,把文件路径、函数名、决策理由全丢了 —— 而那些恰恰是后续对话真正要用的。

所以摘要指令(SUMMARY_INSTRUCTION)必须明确要求:

  • 逐字保留文件路径、函数名、变量名、错误信息。
  • 保留做过的决定和理由("选了 A 而不是 B,因为...")。
  • 写成给未来自己的笔记,不要写成散文。
  • 不要客套话、不要复述问题、不要总结「用户想要什么」。

这段提示词的质量直接决定压缩之后 agent 还记不记得住关键信息。值得反复调整。

上面四条要求说得抽象,看真东西最清楚。下面是三段真实提示词的原文 —— 教程版的、pi 的、以及 Codex 的。点开对照着读,比反复推敲形容词有用。

教程版:SUMMARY_INSTRUCTION(mini_agent/compaction.py)点击展开原文
You are compacting a coding session so it can continue in a fresh context.
Write a dense handoff summary of the conversation below.

You MUST preserve, verbatim where possible:
- file paths that were read or modified
- names of functions, classes, variables that were changed
- decisions made and the reason for them
- what is still unfinished

Do NOT include chit-chat. Do NOT invent anything not present below.
Write it as notes to your future self, not as prose for a human.
pi:SUMMARIZATION_SYSTEM_PROMPT + SUMMARIZATION_PROMPT(packages/agent/src/harness/compaction/compaction.ts)点击展开原文
── system ──
You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified.

Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.

── user ──
The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.

Use this EXACT format:

## Goal
[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]

## Constraints & Preferences
- [Any constraints, preferences, or requirements mentioned by user]
- [Or "(none)" if none were mentioned]

## Progress
### Done
- [x] [Completed tasks/changes]

### In Progress
- [ ] [Current work]

### Blocked
- [Issues preventing progress, if any]

## Key Decisions
- **[Decision]**: [Brief rationale]

## Next Steps
1. [Ordered list of what should happen next]

## Critical Context
- [Any data, examples, or references needed to continue]
- [Or "(none)" if not applicable]

Keep each section concise. Preserve exact file paths, function names, and error messages.
Codex(0.156.0):压缩提示词 —— 从本机 codex.exe 里提取的字符串,同处出现的源码路径是 core/src/compact.rs点击展开原文
You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task.

Include:
- Current progress and key decisions made
- Important context, constraints, or user preferences
- What remains to be done (clear next steps)
- Any critical data, examples, or references needed to continue

Be concise, structured, and focused on helping the next LLM seamlessly continue the work.

三份摆在一起,差别一眼可见:

  • 教程版只给清单:告诉它「必须逐字保住什么」,格式随模型发挥。最短、最好抄;代价是每次压出来的形状不一定一样。
  • pi 把格式钉死了:六段固定小标题(Goal / Constraints & Preferences / Progress / Key Decisions / Next Steps / Critical Context),每段还给一行方括号写好的「这里该填什么」,末尾再强调一次保留精确的路径、函数名、错误信息。它另有两句 Do NOT continue the conversation —— 防止模型把「摘要请求」当新任务接着往下聊。
  • Codex 最短:第一句先说清这是什么任务(CONTEXT CHECKPOINT COMPACTION),第二段给四条 Include。它把「读者是谁」直接写进第一句 —— 这是给接着干活的另一个 LLM 的交接摘要。
  • 三家都写的两件事:说清摘要给谁看;点名必须保留路径、名字、决定、未完成的事。区别只在格式约束有多硬 —— 教程版靠清单,pi 靠模板,Codex 靠一句话加四条。
  • pi 还多一份增量版(UPDATE_SUMMARIZATION_PROMPT):第二次以后压缩时,把新消息并进上一版摘要,而不是把新旧历史从头再压一遍 —— 它的第一条规则就是 PRESERVE all existing information from the previous summary。想省 token 的话,这一招比任何措辞优化都有效。

两个设计细节

摘要用 role="user",不用 assistant。对模型来说,「这些是背景信息」更像是一条用户交代的前提,而不是它自己说过的话。用 assistant 会让它以为那是自己的结论。

摘要请求是一次独立的一次性请求,不往当前对话里追加。否则你压缩历史的行为本身又在增长历史,很荒谬。

设计取舍

本章新增或修改

本章新增 compaction.py,并在 agent.py 里加一个 before_request 钩子把压缩接进循环。目标:当估算的 token 逼近窗口上限时,把最老的一段历史压成一条摘要,让长对话既不撞 API 的硬限制,也不至于越聊越贵越慢。另外在 cli.py 里加一个 /compact 命令,不用等它自动触发,随时能手动压一次。

新增compaction.pyestimate_tokens() 粗估 token,is_turn_start() / find_cut_index() 找安全的切点,compact() / summarize() 做压缩。
修改agent.py新增 make_compaction_hook():每轮请求前判断要不要压,压完用 messages[:] = ... 就地替换历史 —— 保证调用方看到的还是同一个列表对象。
修改cli.py把钩子的组装收成一个方法 _make_before_request():压缩真的发生后顺手把新历史落盘,避免重启回到压缩前;再加 /compact 命令,随时手动压一次。

怎么实现

第 1 步:写 token 估算
  • estimate_tokens 汇总消息、工具名和参数的字符数,做近似 token 估算。
token 估算mini_agent/compaction.py:32–59点击展开关键代码
mini_agent/compaction.py第 32–59 行
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
def estimate_tokens(messages):
    """Estimate tokens from content, tool names, arguments, and tool-call IDs.
    
    Use characters / 3 plus fixed message overhead. This is a rough heuristic
    for mixed English and Chinese text, not an exact model-specific count."""
    total_chars = 0
    for msg in messages:
        content = msg.get("content")
        if isinstance(content, str):
            total_chars += len(content)
        elif content:                       # Fallback for uncommon historical content shapes.
            total_chars += len(str(content))
        for tc in msg.get("tool_calls") or []:
            fn = tc.get("function") or {}
            total_chars += len(str(fn.get("name", "")))
            total_chars += len(str(fn.get("arguments", "")))
        if msg.get("role") == "tool":
            total_chars += len(str(msg.get("tool_call_id", "")))
    # Allow fixed overhead for roles and message structure.
    overhead = len(messages) * 12
    return total_chars // 3 + overhead


def needs_compaction(messages, limit=None, reserve=None):
    """Decide whether estimated tokens have reached the reserved threshold."""
    limit = limit if limit is not None else CONFIG["max_context_tokens"]
    reserve = reserve if reserve is not None else CONFIG["compaction_reserve_tokens"]
    return estimate_tokens(messages) >= (limit - reserve)
第 2 步:写轮的判据(关键)
  • is_turn_start 用 user 角色和字符串内容识别新一轮;工具结果使用 tool 角色。
轮的判据 is_turn_start(关键)mini_agent/compaction.py:66–71点击展开关键代码
mini_agent/compaction.py第 66–71 行
66
67
68
69
70
71
def is_turn_start(msg):
    """Identify a user text message that starts a conversation turn."""

    if msg.get("role") != "user":
        return False
    return isinstance(msg.get("content"), str)
第 3 步:写切点计算
  • find_cut_index 从轮次开头中选切点,保留最近若干轮。
切点计算 find_cut_indexmini_agent/compaction.py:74–91点击展开关键代码
mini_agent/compaction.py第 74–91 行
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
def find_cut_index(messages, keep_recent_turns=None):
    """Find a complete-turn boundary before the retained recent turns.
    
    messages[:cut] will be summarized; messages[cut:] stays unchanged.
    Return None when history is too short for a useful cut."""
    keep = (
        keep_recent_turns
        if keep_recent_turns is not None
        else CONFIG["compaction_keep_recent_turns"]
    )

    # Count recent turn starts backward and cut before them.
    turn_starts = [i for i, m in enumerate(messages) if is_turn_start(m)]
    if len(turn_starts) <= keep:
        return None                      # Too few turns to compact.

    cut = turn_starts[len(turn_starts) - keep]
    return cut if cut > 0 else None      # A cut at zero would summarize nothing.
第 4 步:写 compact 和 summarize
  • compact 把旧历史交给 summarize,保留近期原文,再返回新列表。
  • summarize 用独立请求生成摘要,_render 负责组织输入文本。

渲染摘要输入的 _render(mini_agent/compaction.py 第 138–157 行)

def _render(messages):
    """Render messages and tool calls as text for the summary request."""
    lines = []
    for msg in messages:
        role = msg.get("role", "?")
        content = msg.get("content")
        if isinstance(content, str) and content:
            lines.append(f"[{role}] {content}")
        # Assistant tool calls.
        for tc in msg.get("tool_calls") or []:
            fn = tc.get("function") or {}
            lines.append(f"[assistant] called {fn.get('name')} with {fn.get('arguments')}")
        # Tool-result messages.
        if role == "tool":
            body = str(content or "")
            if len(body) > 500:
                body = body[:500] + " …(truncated)"
            lines.append(f"[tool] result: {body}")
    return "\n".join(lines)

这段直接来自当前 compaction.py 的 _render 函数。

摘要指令的措辞mini_agent/compaction.py:13–25点击展开关键代码
mini_agent/compaction.py第 13–25 行
13
14
15
16
17
18
19
20
21
22
23
24
25
SUMMARY_INSTRUCTION = """\
You are compacting a coding session so it can continue in a fresh context.
Write a dense handoff summary of the conversation below.

You MUST preserve, verbatim where possible:
- file paths that were read or modified
- names of functions, classes, variables that were changed
- decisions made and the reason for them
- what is still unfinished

Do NOT include chit-chat. Do NOT invent anything not present below.
Write it as notes to your future self, not as prose for a human.
"""
压缩主流程 compact / summarizemini_agent/compaction.py:98–135点击展开关键代码
mini_agent/compaction.py第 98–135 行
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
def compact(messages, *, signal=None, keep_recent_turns=None):
    """Return a summary followed by recent history without mutating the input.
    
    Use a user-role summary to present background information, rather than
    making it appear to be the assistant's own previous answer."""
    cut = find_cut_index(messages, keep_recent_turns)
    if cut is None:
        return messages                  # Nothing to compact; return the original list.

    older = messages[:cut]
    recent = messages[cut:]

    summary = summarize(older, signal=signal)
    header = "Here is a summary of the earlier part of this session:\n\n"
    summary_msg = {"role": "user", "content": header + summary}

    return [summary_msg] + recent


def summarize(messages, *, signal=None):
    """Generate handoff notes in a separate model request with no tools."""
    # Render older history as plain text for the model.
    # This is a separate summary request.
    # It does not append to the ongoing conversation.
    transcript = _render(messages)
    prompt = [
        {
            "role": "user",
            "content": f"{SUMMARY_INSTRUCTION}\n\n<conversation>\n{transcript}\n</conversation>",
        }
    ]
    # No tools are needed for summarization.
    response = llm.call_llm(prompt, [], system=None, signal=signal)
    text = llm.extract_text(response).strip()
    if not text:
        # Keep a recognizable placeholder if the model returns no summary.
        return "(summary unavailable)"
    return text
第 5 步:接进循环,注意「就地替换」
  • make_compaction_hook 把返回新列表的接口包装成就地替换,agent_loop 在请求前调用它。
  • App._make_before_request 在历史长度变化后写回磁盘;/compact 的分支在 App.handle_command 中。
接进循环要用「就地替换」mini_agent/agent.py:256–280点击展开关键代码
mini_agent/agent.py第 256–280 行
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
    Slice assignment preserves the list shared by the loop and session."""
    from . import compaction as _compaction

    def hook(messages):
        if not _compaction.needs_compaction(messages):
            return
        before = _compaction.estimate_tokens(messages)
        if emit:
            emit("compaction_start", tokens=before)

        compressed = _compaction.compact(messages, signal=signal)

        # Preserve the list identity with slice assignment.
        # Assigning messages = compressed would only rebind a local variable.
        # The caller would still hold the unchanged original list.
        messages[:] = compressed

        if emit:
            emit(
                "compaction_end",
                before=before,
                after=_compaction.estimate_tokens(messages),
            )

    return hook
循环里的插入点:每轮问模型之前mini_agent/agent.py:188–193点击展开关键代码
mini_agent/agent.py第 188–193 行
188
189
190
191
192
193
        # Step 4: compaction check (chapter 6).
        # Compact before sending the next model request.
        # The hook must replace list contents in place, not assign a new list.
        # This preserves references held by the caller and session.
        if before_request is not None:
            before_request(messages)
把压缩钩子包一层,顺便落盘mini_agent/cli.py:116–131点击展开关键代码
mini_agent/cli.py第 116–131 行
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
    def _make_before_request(self):
        """Wrap automatic compaction and persist replaced history.
        
        The append hook only saves new messages. Compaction replaces old messages,
        so the session file must be rewritten rather than appended to."""
        compact_hook = agent_mod.make_compaction_hook(
            signal=self.signal, emit=self.render
        )

        def before_request(messages):
            before_len = len(messages)
            compact_hook(messages)
            if len(messages) != before_len:
                self.session.replace_messages(messages)

        return before_request
手动压缩命令 /compactmini_agent/cli.py:223–228点击展开关键代码
mini_agent/cli.py第 223–228 行
223
224
225
226
227
228
        elif cmd == "/compact":
            before = compaction.estimate_tokens(self.session.messages)
            compacted = compaction.compact(self.session.messages, signal=self.signal)
            self.session.replace_messages(compacted)
            after = compaction.estimate_tokens(compacted)
            print('{0}Compacted: {1} → {2} tokens{3}'.format(DIM, before, after, RESET))
第 7 章

系统提示词与技能

让它知道自己在哪
目标 把写死的系统提示词变成「骨架 + 动态环境 + 外部技能文件」三段拼接。

要解决的问题

原理

前六章的系统提示词是一行写死的字符串。它有两个明显不足:不知道自己在哪(今天几号、在哪个目录、什么系统),也没法扩展(想加项目专属知识就得改代码)。

这一章把它变成三段拼出来的东西。看起来简单,但三个设计点都有实际理由。

为什么要分三层,以及顺序为什么有讲究

大模型对上下文不同位置的注意力不是均匀的 ——开头和结尾最强,中间最弱(这就是所谓的「lost in the middle」)。

所以排布要按「重要性 × 稳定性」来定:

  • 第 1 层 · 骨架:最关键的约束(你该怎么干活、怎么说话),放最前面。比如「Read before you write」这一条,能显著减少模型凭想象改文件的情况。
  • 第 3 层 · 技能:项目专属知识,放最后面(靠近结尾,注意力也强)。
  • 第 2 层 · 环境夹在中间。它是纯事实、不需要模型特别关注,放在中间最合适。

骨架要写「风格」,不要写「知识」。「你应该先读再写」是风格,任何项目都成立;「这个项目的提交信息要用中文」是知识,应该做成技能文件。

环境信息:两种「缓存」要分开看

这里要区分两件事:本地保存环境信息,是为了少读、少算;服务端的提示词缓存,是为了复用已经计算过的请求前缀。前者要考虑信息是否过期,后者要考虑前缀是否稳定。

  • 本地缓存可以用,但要有更新时机。平台信息通常比较稳定;目录、日期、权限等发生变化时,需要重新采集。不能简单地把所有环境信息存一次就一直使用,也不必把所有字段每次都重算。
  • 服务端缓存复用相同的前缀。靠前的内容变化,会影响包含这处变化的较长前缀;变化之前的稳定部分仍有机会复用。实际命中还取决于模型、缓存边界、长度门槛、保留时间和请求路由,不能保证一天内一直命中。

这里的基础实现:environment_block() 每次被调用时读取目录、平台和日期,但 CLI 在创建 App 时只调用一次 build_system_prompt(),后续请求复用 self.system_prompt。因此它实际上是一份启动时快照,长会话跨天后不会自动更新。这里保留这份简单实现。

更好的做法:固定指令保持稳定,环境变化作为后续上下文补充

Codex 的公开源码提供了一个可以参考的做法:把环境当成有状态的上下文,保存快照、比较变化,再生成更新片段。这样既能给模型较新的事实,也能尽量保留之前已经发送的内容。

  • 先建立环境快照:EnvironmentsState 保存工作目录、shell、日期、时区、网络和文件系统权限等信息;首次没有旧快照时,按空状态比较,生成需要的环境上下文。
  • 后续比较变化:render_diff() 对照上次快照,目录或其他环境字段变化时生成更新;没有相关变化时,不生成环境片段。多环境场景还会区分新增、变化和不可用的环境。
  • 环境事实作为独立上下文:更新渲染成带 <environment_context> 标签的 user 角色片段。这里的 user 角色是客户端组织上下文的方式,并不表示这段内容是用户手写的。日期也在快照比较中,不能笼统说成“时间完全不进环境上下文”。

如果继续扩展这里的 agent,可以采用一个小一点的版本:固定骨架和当前会话使用的技能组成稳定指令;开始会话时补一份环境说明;每轮开始时检查目录、日期等字段,变化时在历史后面追加更新,并明确以最新环境说明为准。不要为了更新时间,反复改写历史开头那份提示词。压缩历史或恢复会话后,也要确保模型仍能看到有效的当前环境。

例如:昨天的环境说明和已有对话保留原样,跨天后补一条“当前日期已更新为……”;切换目录后补一条新的目录说明。需要精确时间的任务,可以按需读取时钟。这样不必把每秒变化的时间戳放进固定指令。

这种组织方式有利于保留可复用前缀,但不保证缓存命中。真正是否命中,应看 API 返回的 cached_tokens;具体字段和计费、缓存设置以所用模型及服务商的文档为准。这里使用兼容 Chat Completions 的接口,也不能假定所有后端都采用 OpenAI 的缓存规则。

参考:Codex 环境快照与差异更新源码(核对于 2026-10-12);OpenAI 提示词缓存文档。这是可选的改进方向,下面的代码仍使用启动时组装提示词的基础写法。

技能:为什么是文件,而不是代码

一个「技能」就是一个 markdown 文件,讲清某类任务该怎么做。比如 git-commit.md 里写好本项目的提交信息规范。

选择文件而不是代码,有三个好处:

  • 改它不需要碰代码,也不需要重新验证程序。写文档的人不用会写 Python。
  • 可以按项目分别准备:换一个项目就换一套 skills/ 目录。
  • 内容是自然语言,正好是模型最擅长理解的形式。你不需要定义任何 schema。

这里还有个重要的设计原则:技能是可选增强,缺了不能报错。目录不存在时返回空列表,单个文件读不了就跳过。一个增强功能的失败不应该阻断主流程 ——这和第 2 章「工具失败要返回错误文本而不是崩溃」是同一种思路。

一个容易被忽略的点

系统提示词是每次请求都发的。所以它越长,每一轮的固定开销就越大。

这意味着把整个项目的文档塞进技能文件是个坏主意。技能应该写精炼的规则(「提交信息用中文,第一行不超过 50 字」),而不是背景资料。

顺带一提:这也解释了为什么真实的 coding agent 会把工具说明放在工具声明里(按需)、把项目文档做成可检索的(agent 自己去读),而不是全塞进系统提示词。系统提示词的每一行都在为整个会话付费。

设计取舍

本章新增或修改

本章新增 prompt.py,并在 cli.py 里把写死的提示词换成 build_system_prompt()。目标:把系统提示词从一行字符串变成「固定骨架 + 动态环境 + 磁盘上的技能文件」三段拼接 —— 以后加项目专属知识只要放一个 .md,不用碰代码。

新增prompt.pyBASE_PROMPT(骨架)、environment_block()(每次重算的日期/目录/系统)、load_skills() + skills_block()(从 skills/*.md 加载)、build_system_prompt()(拼装)、describe()(查看加载情况)。
修改cli.py启动时算一次提示词并复用;/skills 命令列出当前加载了哪些技能文件。

怎么实现

第 1 步:写第 1 层,骨架
  • 查看 mini_agent/prompt.py 的 BASE_PROMPT,固定的行为约定放在这里。
第 1 层:骨架mini_agent/prompt.py:16–29点击展开关键代码
mini_agent/prompt.py第 16–29 行
16
17
18
19
20
21
22
23
24
25
26
27
28
29
BASE_PROMPT = """\
You are a coding agent working in a terminal.

How you work:
- Act, don't explain. If the user asks for a change, make the change.
- Read before you write. Never edit a file you have not just read.
- Prefer the `edit` tool over rewriting a whole file with `write`.
- When a tool returns an error, read the message and fix the call.
- Stop when the task is done. Do not keep poking around.

How you talk:
- Be terse. Report what you did, not what you are about to do.
- Use the user's language.
"""
第 2 步:写第 2 层,环境信息
  • environment_block 读取当前目录、平台和日期,拼成环境信息。
第 2 层:环境信息mini_agent/prompt.py:32–39点击展开关键代码
mini_agent/prompt.py第 32–39 行
32
33
34
35
36
37
38
39
def environment_block():
    """Read the working directory, platform, and date when called."""
    return (
        "\nEnvironment:\n"
        f"- working directory: {os.getcwd()}\n"
        f"- platform: {platform.system()} {platform.release()}\n"
        f"- today: {datetime.date.today().isoformat()}\n"
    )
第 3 步:写第 3 层,从磁盘加载技能
  • load_skills 读取 skills 目录中的 Markdown 文件,skills_block 拼接正文。
第 3 层:从磁盘加载技能mini_agent/prompt.py:51–87点击展开关键代码
mini_agent/prompt.py第 51–87 行
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
SKILLS_DIRNAME = "skills"


def load_skills(root=None):
    """Load nonempty Markdown files from the working directory's skills folder.
    
    Return dictionaries with name and body. A missing directory is allowed;
    this teaching implementation skips individual unreadable files."""
    root = root or os.getcwd()
    skills_dir = os.path.join(root, SKILLS_DIRNAME)
    if not os.path.isdir(skills_dir):
        return []

    skills = []
    for name in sorted(os.listdir(skills_dir)):
        if not name.lower().endswith(".md"):
            continue
        path = os.path.join(skills_dir, name)
        try:
            with open(path, "r", encoding="utf-8", errors="replace") as f:
                body = f.read().strip()
        except OSError:
            continue                       # Skip an unreadable optional skill file.
        if body:
            skills.append({"name": os.path.splitext(name)[0], "body": body})
    return skills


def skills_block(skills=None, root=None):
    """Render skill contents, returning an empty string when no skills exist."""
    skills = load_skills(root) if skills is None else skills
    if not skills:
        return ""
    parts = ["\nSkills (follow these when relevant):"]
    for skill in skills:
        parts.append(f"\n--- {skill['name']} ---\n{skill['body']}")
    return "\n".join(parts) + "\n"
第 4 步:拼起来
  • build_system_prompt 按骨架、环境、技能的顺序拼接。
  • 提示词要求模型跟随用户使用的语言;CLI 固定使用英文。
拼起来mini_agent/prompt.py:90–94点击展开关键代码
mini_agent/prompt.py第 90–94 行
90
91
92
93
94
def build_system_prompt(skills=None, root=None):
    """Combine behavior instructions, environment information, and skills.
    
    Pass skills=[] to disable skills, or None to load them from disk."""
    return BASE_PROMPT + environment_block() + skills_block(skills, root)
第 5 步:在 CLI 里接上
  • App.__init__ 加载技能并构造 system_prompt,run_turn 将它传给 agent_loop。
  • App.handle_command 中的 /skills 分支显示已加载的技能。
在 CLI 里接上mini_agent/cli.py:74–83点击展开关键代码
mini_agent/cli.py第 74–83 行
74
75
76
77
78
79
80
81
82
83
    def __init__(self, args):
        self.args = args
        self.session = self._open_session()
        self.signal = Signal()
        self.steering = SteeringQueue()
        self.executor = agent_mod.make_executor()
        self.render = make_renderer(args.verbose)
        self.skills = prompt.load_skills()
        self.system_prompt = prompt.build_system_prompt(self.skills)
        self.before_request = self._make_before_request()
技能查看命令 /skillsmini_agent/cli.py:216–222点击展开关键代码
mini_agent/cli.py第 216–222 行
216
217
218
219
220
221
222
        elif cmd == "/skills":
            if not self.skills:
                print('(No skills loaded. Add .md files under ./skills/)')
            for s in self.skills:
                print('  {0}  ({1} characters)'.format(s['name'], len(s['body'])))
            total = len(prompt.build_system_prompt(self.skills))
            print('{0}System prompt: {1} characters total{2}'.format(DIM, total, RESET))