教程默认使用英文,可通过顶部语言链接切换中文。源码注释、文档字符串和 CLI 提示全部使用英文;模型回复跟随用户语言。
这份个人整理主要参考了 pi 和 Codex 的源码。
要理解 agent loop,先要接受一个有点反直觉的前提:大模型本身什么也做不了。它是一个纯粹的函数 —— 给它一段文本,它还你一段文本,然后就忘了。它没有记忆、没有手、没有状态。
所以「agent」不是模型的能力,是你写的那个循环。循环的唯一职责是:把模型输出的「我想做某事」翻译成真实动作,再把动作的结果翻译回文本喂给它。智能全在模型里,你的代码只负责搬运和记账。
模型说「我要调工具」时,它并没有执行任何东西。它只是在输出的 JSON 里多塞了一个 tool_calls 数组,每项形如 {id: "call_01...", function: {name: "read", arguments: "{...}"}}。其中 arguments 是被字符串化的 JSON 参数。这个数组的意思是「如果有一双手,我希望它去读这个文件」—— 执行与否完全取决于你。
理解这一点,整个架构就顺了:循环就是那双手。模型负责想,你负责做,做完再把结果告诉它。
在动手写循环之前,得先知道一次请求长什么样。整个协议其实只有一个入口:向 {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/createchoices → message → content / tool_calls,以及同层的 finish_reason):api-reference/chat/objectsystem / user / assistant / tool)怎么配合、完整对话示例:api-reference/chattools 声明、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 和 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 直接报错。
tool_calls 数组。不要去看 finish_reason —— 那是服务商的元数据,命名不统一;而「assistant 消息里有没有 tool_calls」是你亲手构造的事实,永远可靠。content 还有 tool_calls 数组,下一轮要靠里面的 id 去配对 role="tool" 的结果。只存文字会导致下一轮请求失败。本章从零搭出 mini_agent/ 这个包的骨架:config.py 把可调项集中一处,llm.py 是统一处理模型请求的地方,agent.py 写主循环,cli.py 让它在终端里真的跑起来。写完这章,你就有了一个能自己连着调工具干活的 agent —— 后面会在这份代码上逐步补充,尽量沿用已有的结构。
call_llm() 发一次请求;extract_message / extract_text / extract_tool_calls 从响应里取消息、文字、工具调用。agent_loop():问模型 → 原样存消息 → 有 tool_calls 就执行 → 结果回灌 → 再问,直到没有 tool_calls。-v 控制要不要打工具输出。mini_agent 成为一个可 import 的包。from .cli import main),让 python -m mini_agent 能启动。mini_agent/config.py 的 CONFIG,模型地址、密钥和模型名从环境变量读取。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' ) |
mini_agent/llm.py 的 call_llm 把 system 与历史拼成请求,并发送到 /chat/completions。请求模块的导入与默认提示词(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 时也会使用这个默认常量。
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 |
extract_message、extract_text、extract_tool_calls;消息保留 content 和工具调用信息。① 模型只想说话: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() 一次。
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 [] |
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 内。
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"]] |
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 |
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。
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 |
第 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 里,错误是喂给模型的输入。所以工具永远返回字符串,从不抛异常。
API 的 tools 参数要求每个工具提供:名字、说明、以及一个 JSON Schema 描述参数。这份声明会被原样序列化进请求体,模型完全靠它来决定「我该怎么填参数」。
所以你的代码里要有两份表:
TOOLS —— 给模型看的声明。写得含糊,模型就会填错参数。HANDLERS —— 给自己用的实现字典。两份表必须同名。名字对不上,模型会看到一个「声明存在、但调用时必然失败」的工具 —— 这种 bug 很隐蔽,因为模型不会说「这个工具没实现」,它只会反复重试。
一个很自然的想法是:读文件就执行 cat xxx,写文件就 echo ... > xxx。不要这么做,有三个原因:
cat *.py 会被 shell 展开成一堆文件,而模型的本意可能就是一个叫 *.py 的文件。open() 的话,OSError 会明确告诉你原因。路径就是路径,内容就是内容。shell 只留给真正需要跑命令的场景(调用 git、跑测试)。
模型可能给出 ../../etc/passwd,或者 C:\Windows\System32\...。这不是恶意,它只是在猜。但你的工具不能真的去读。
解法是把路径解析成绝对路径,再确认它落在工作目录的子树内。关键是必须用 os.path.realpath 而不是 abspath —— 前者会展开符号链接。否则一个指向外部的软链接就能绕过检查。
TOOLS 是给模型看的声明(名字、说明、参数 schema);HANDLERS 是给自己用的实现表。两者必须同名,否则模型会看到一个「存在但调不动」的工具。(input: dict) -> str,这样所有工具才能塞进同一张表。"Error: ..." 给模型,让它自己想别的办法 —— 这比让程序崩溃有用得多。cat xxx:没有引号转义问题、没有 glob 展开,路径就是路径。本章只新增 tools.py 一个文件,再让 agent.py 把「执行工具」改成调用统一入口 submit()。目标:给 agent 一双手 —— 它能读文件、改文件、跑命令,而主循环一行都不用改;要加一个新工具,也只需要动 tools.py。
TOOLS 声明(OpenAI function schema),下半部分是给自己用的 HANDLERS 实现表和 submit() 入口,外加 safe_path() 把模型给的路径关进工作目录。tools.submit(name, args) —— 循环从此不关心有哪些工具。mini_agent/tools.py 的 _function 与 TOOLS,声明 bash、read、write、edit 四个工具。工具模块的导入(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 示例用来解释参数结构。
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"], ), ] |
safe_path 使用 realpath 解析路径,再检查目标是否在工作目录中。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 |
tool_bash、tool_read、tool_write,入参都是字典,返回值都是文本。第 1 章的 config.py 里补这个键(read 要用)
CONFIG = {
# ……第 1 章的键都留着,这里只加一行
"max_read_lines": 2000,
}
tool_read 读的就是 CONFIG 里的这个键。第 1 章那版 config.py 里没有它,漏掉会在读文件时直接抛 KeyError。2000 的意思是「一次最多读 2000 行」,更长的文件让模型用 offset / limit 自己分页。
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)" |
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) |
tool_edit 先读取内容,再检查 old_string 的匹配次数,只有唯一匹配才替换。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) |
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,与工具模块的声明保持一致。
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}" |
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} |
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 行。这类「少而关键」的并发原语,长度短但每行都有理由,值得逐行读懂。
./a.txt、a.txt、符号链接会被当成三个不同的 key,各自建一把锁,队列形同虚设。本章新增 mutation_queue.py,改动 tools.py 的 write / edit 两处让写盘排队,并把 agent.py 的工具执行从串行改成线程池。目标:多个工具能并发跑得快,同时保证同一个文件的「读-改-写」不会互相覆盖。
run(path, fn) 是一个文件变更的唯一入口。write / edit 把整个「读 → 查重 → 写」序列包成一个闭包交给 mutation_queue.run() —— 排队粒度是整段,不是最后一次写盘。ThreadPoolExecutor,但结果必须按原调用顺序收集 —— 顺序错了模型就对不上号。FileMutationQueue._key_for,用规范化的绝对路径作为分桶键。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)) |
_registry 保护锁表:同一个 key 共用同一把锁。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 |
run(path, fn) 取得目标文件的锁后调用 fn;不同 key 使用不同锁。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() |
tool_edit 和 tool_write 都把实际变更动作交给 mutation_queue.run。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) |
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))
这些都是当前实现的实际调用点,参数名与源文件保持一致。
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] |
tool_calls、没有对应 role="tool" 消息的 assistant 消息 —— 下次请求会被 API 直接拒绝。agent 跑一轮可能要几十秒(模型思考慢 + 工具执行慢)。如果主线程傻等,你既没法叫停,也没法补话 —— 只能干看着它烧钱。
要解决这个,需要两样互不相同的东西:中断(我不想让你继续了)和转向(我想让你改个方向)。它们看起来像同一件事,但机制完全不同,所以要两个类。
中断可能发生在很深的地方:正在 urlopen 等着 HTTP 响应、正在等待一个子进程结束、正在递归的某个工具函数里。
如果用返回值传播,每个函数的签名都要变成 -> tuple[result, aborted],每一层都要检查并向上传递。这会把代码污染得没法看,而且只要漏掉一层,中断就失效。
异常天生就是为「跨越很多层直接跳出去」设计的。所以中断用异常:在最外层捕获,在检查点抛出。
代价是异常会跳过正常的清理逻辑,所以检查点的位置要慎重选 —— 不能扔在「已经改了内存、还没写磁盘」这种中间状态上。
第 4 章同时要把 agent 放到后台线程里跑(主线程才能继续读你的输入)。于是中断标志就变成了跨线程共享的可变状态。
一个普通的 self.flag = True 在这里是危险的:Python 有 GIL,简单赋值一般不会读到撕裂的值,但内存可见性和指令重排没有任何保证。threading.Event 内部有锁,是标准做法,也表达了「这是个同步点」的意图。
你可能在 agent 跑的时候连打两三句话。用单个变量存的话,后一句会覆盖前一句,前一句就丢了。所以用队列。
更关键的是取走的时机:请求已经发出去了,你这句话没法塞进当前这轮,只能等下一轮开始前插进历史。所以队列必须在循环的每轮开头被清空一次。
而「清空」这个动作必须是原子的:如果先 len() 判断再逐个 pop,中间主线程又 push 了一条,就可能漏掉或重复。所以要用「一次性取走并替换成空列表」的写法,而不是循环 pop。
这里需要注意工具调用与结果的配对,可以用测试检查。
时序是这样的:模型回复「我要调 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 章引入中断之后才真正被违反。每加一个新功能,都要回头检查它是否破坏了之前的约束。
role="tool" 消息。这里需要保证工具调用和结果完整配对。本章新增 control.py,在 agent.py 的循环里插四个检查点,在 llm.py 里插两次信号检查,最后把 cli.py 的 main() 升级成一个 App 类、让 agent 跑在后台线程里。目标:agent 在跑的那几十秒里,你随时能 Ctrl+C 叫停,也能顺手补一句话改变它的方向。
Aborted 异常 + Signal(用 threading.Event 做的跨线程中断信号)+ SteeringQueue(转向消息队列,drain() 是「取走并清空」的原子动作)。role="tool" 消息,避免留下孤儿 tool_call。main() 拆成一个 App 类(__init__ 装状态、repl() 读输入、handle_command() 管 / 命令),主线程启动 worker 后立刻回到 input():打字进转向队列,Ctrl+C 调 signal.abort()。mini_agent/control.py 的 Signal 与 SteeringQueue,分别存储中断状态和补充输入。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) |
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 |
agent_loop 开头检查中断、读取转向队列并判断轮数上限。_run_one 也会检查 signal。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 |
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 |
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} |
call_llm 在发请求前和收到响应后检查 signal。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 |
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 |
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 章说明的会话、压缩与提示词接线。
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)) |
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) |
--continue 能恢复上次那一段。前四章的 agent 一关就忘。而上下文恰恰是 agent 最贵的东西 ——里面有你花掉的 token、agent 读过的文件、它做过的决策。丢掉它等于把之前所有的钱和时间都浪费了。
最直觉的做法是把整个 messages 列表序列化成一个 .json 文件。但每追加一条消息,都要:读出整个文件 → 解析 → push → 再序列化 → 整份覆盖写。
改成 JSONL(一行一条 JSON)之后,追加就只是 f.write(line + "\n")。三个理由,按重要性排序:
这个思路有个名字叫事件日志(event log):只追加、不修改,当前状态由重放日志算出来。数据库的 WAL、Git 的 commit 历史 都是同一个套路。
有一种操作没法用「追加」表达:整体重写。第 6 章的压缩会把前 N 条换成 1 条摘要 —— 这不是追加,是替换。
如果直接打开原文件覆盖写,写到一半崩溃就直接毁掉整个会话。所以要先写 xxx.jsonl.tmp,写完再 os.replace(tmp, path)。
os.replace 在 Windows 和 POSIX 上都是原子的:要么看到旧文件,要么看到新文件,不存在中间状态。这是保证「崩溃时数据不损坏」的标准手法。
落盘这件事只有两个动作:改内存(把消息加进对话列表)和写磁盘(往这一行的 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 记录应描述同一份历史。
还有一个类似的陷阱。replace_messages 要整体替换历史,直觉写法是 self.messages = messages。也是错的。
因为 agent_loop 和 CLI 都持有原来那个 list 对象。你让 self.messages 指向一个新列表,旧列表仍然被它们拿着 ——之后的 append 会写进一个已经没人看的列表,内存与磁盘从此永久脱钩。
正确写法是 self.messages[:] = messages —— 切片赋值是就地修改,所有持有者都能看到新内容。这个区别在 Python 里非常常见,值得单独记住:= 换对象,[:]= 换内容。
append 是「新增一条消息」,replace_messages 是「整体重写」(第 6 章压缩要用)。本章新增 session.py,在 agent.py 里加一个 _append 间接层加 on_append 钩子,并在 cli.py 里接上会话。目标:对话落到磁盘上,关掉终端之后还能 --continue 接着聊。顺带留出「整体重写历史」的入口,以后要整段换掉历史时有地方下手。
create / load / append / append_message / persist / replace_messages,外加 list_sessions() 和 latest_session()。_append(),并新增 on_append 钩子 —— 循环只负责改内存,落盘交给 session。/new / /resume / /list 都走 session;用户输入先落盘再发给模型,请求失败也不会把你说的话弄丢。Session 的 id、messages、path,以及 create 如何确定会话文件。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) |
Session.load 逐行读取 JSONL,当前基础实现遇到无法解析的行会跳过并提示。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) |
append 与 append_message 同时修改内存和写盘;persist 只写盘。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 |
replace_messages 用切片替换保持列表对象不变,再通过临时文件重写历史。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 { |
mini_agent/agent.py 的 _append 负责通知持久化钩子;App.run_turn 传入 session.persist。App._open_session 处理恢复参数,App.handle_command 中的 /new、/list、/resume 管理会话。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 |
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 |
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)) |
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() |
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) |
role="tool" 消息,同样导致请求被拒。第 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? 没有推导,是拍的 —— 够装下「正在做的那件事」就行。真正值得讲的是这个数该按什么取舍。
先看一个容易走偏的直觉:既然目的是别超窗口,那「压到刚好低于阈值」不是最省吗?不是。因为压缩不是一次性动作,而是会反复发生的 —— 只压到贴着阈值,下一轮的回答加工具输出立刻把体积推回去,于是每轮都要再压一次;摘要叠摘要,上下文很快退化成一层层转述。
所以真正要问的不是「压多少才不超窗口」,而是「压完之后还能自由地跑几轮」。这就是「保留最近几轮」的实质:留出一段完整轮的原文当工作区,让接下来几轮都不必立刻再压。
那用什么单位量这段工作区?三种单位都有真实产品在用,取舍不同:
不管用哪种单位,三条原则是共同的:
tool 消息(上一节那条约束)。两边的真实实现都在下面,点开看源码和提取出来的字符串 —— 它们的取舍比教程版讲究,但也更复杂。
// 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,
};
}// 配置里能调的旋钮(同处出现的源码路径: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,得用对应模型的 tokenizer 逐字计算。那意味着引入依赖、而且换模型就要换 tokenizer。
这里用 字符数 / 3 就够了 —— 对中英文混排是个粗糙但可用的近似。关键是要留足余量:估多了顶多提前压一次(浪费一点钱),估少了就会真的超窗口然后请求失败。
但「字符数」只算了 content。一个会漏算的地方:assistant 的 tool_calls(每个调用的函数名 + arguments)以及 role="tool" 消息的 tool_call_id,它们同样要占 token。漏了它们,history 的实际容量会被低估,压缩时机就会被推迟。
所以配置里要有一个 compaction_reserve_tokens(预留量),触发条件是 估算值 > 上限 - 预留量,而不是 > 上限。
如果只说「总结一下这段对话」,模型很可能写成给人看的散文,把文件路径、函数名、决策理由全丢了 —— 而那些恰恰是后续对话真正要用的。
所以摘要指令(SUMMARY_INSTRUCTION)必须明确要求:
这段提示词的质量直接决定压缩之后 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.
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.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.
三份摆在一起,差别一眼可见:
Do NOT continue the conversation —— 防止模型把「摘要请求」当新任务接着往下聊。CONTEXT CHECKPOINT COMPACTION),第二段给四条 Include。它把「读者是谁」直接写进第一句 —— 这是给接着干活的另一个 LLM 的交接摘要。UPDATE_SUMMARIZATION_PROMPT):第二次以后压缩时,把新消息并进上一版摘要,而不是把新旧历史从头再压一遍 —— 它的第一条规则就是 PRESERVE all existing information from the previous summary。想省 token 的话,这一招比任何措辞优化都有效。摘要用 role="user",不用 assistant。对模型来说,「这些是背景信息」更像是一条用户交代的前提,而不是它自己说过的话。用 assistant 会让它以为那是自己的结论。
摘要请求是一次独立的一次性请求,不往当前对话里追加。否则你压缩历史的行为本身又在增长历史,很荒谬。
tool_calls)与 role="tool" 结果。判据是:role == "user" 且 content 是纯字符串(工具结果有自己的 role="tool",天然不会误判;只有 user + 字符串才算新一轮开头)。本章新增 compaction.py,并在 agent.py 里加一个 before_request 钩子把压缩接进循环。目标:当估算的 token 逼近窗口上限时,把最老的一段历史压成一条摘要,让长对话既不撞 API 的硬限制,也不至于越聊越贵越慢。另外在 cli.py 里加一个 /compact 命令,不用等它自动触发,随时能手动压一次。
estimate_tokens() 粗估 token,is_turn_start() / find_cut_index() 找安全的切点,compact() / summarize() 做压缩。make_compaction_hook():每轮请求前判断要不要压,压完用 messages[:] = ... 就地替换历史 —— 保证调用方看到的还是同一个列表对象。_make_before_request():压缩真的发生后顺手把新历史落盘,避免重启回到压缩前;再加 /compact 命令,随时手动压一次。estimate_tokens 汇总消息、工具名和参数的字符数,做近似 token 估算。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) |
is_turn_start 用 user 角色和字符串内容识别新一轮;工具结果使用 tool 角色。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) |
find_cut_index 从轮次开头中选切点,保留最近若干轮。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. |
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 函数。
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. """ |
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 |
make_compaction_hook 把返回新列表的接口包装成就地替换,agent_loop 在请求前调用它。App._make_before_request 在历史长度变化后写回磁盘;/compact 的分支在 App.handle_command 中。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 |
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) |
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 |
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)) |
前六章的系统提示词是一行写死的字符串。它有两个明显不足:不知道自己在哪(今天几号、在哪个目录、什么系统),也没法扩展(想加项目专属知识就得改代码)。
这一章把它变成三段拼出来的东西。看起来简单,但三个设计点都有实际理由。
大模型对上下文不同位置的注意力不是均匀的 ——开头和结尾最强,中间最弱(这就是所谓的「lost in the middle」)。
所以排布要按「重要性 × 稳定性」来定:
骨架要写「风格」,不要写「知识」。「你应该先读再写」是风格,任何项目都成立;「这个项目的提交信息要用中文」是知识,应该做成技能文件。
这里要区分两件事:本地保存环境信息,是为了少读、少算;服务端的提示词缓存,是为了复用已经计算过的请求前缀。前者要考虑信息是否过期,后者要考虑前缀是否稳定。
这里的基础实现: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 里写好本项目的提交信息规范。
选择文件而不是代码,有三个好处:
skills/ 目录。这里还有个重要的设计原则:技能是可选增强,缺了不能报错。目录不存在时返回空列表,单个文件读不了就跳过。一个增强功能的失败不应该阻断主流程 ——这和第 2 章「工具失败要返回错误文本而不是崩溃」是同一种思路。
系统提示词是每次请求都发的。所以它越长,每一轮的固定开销就越大。
这意味着把整个项目的文档塞进技能文件是个坏主意。技能应该写精炼的规则(「提交信息用中文,第一行不超过 50 字」),而不是背景资料。
顺带一提:这也解释了为什么真实的 coding agent 会把工具说明放在工具声明里(按需)、把项目文档做成可检索的(agent 自己去读),而不是全塞进系统提示词。系统提示词的每一行都在为整个会话付费。
本章新增 prompt.py,并在 cli.py 里把写死的提示词换成 build_system_prompt()。目标:把系统提示词从一行字符串变成「固定骨架 + 动态环境 + 磁盘上的技能文件」三段拼接 —— 以后加项目专属知识只要放一个 .md,不用碰代码。
BASE_PROMPT(骨架)、environment_block()(每次重算的日期/目录/系统)、load_skills() + skills_block()(从 skills/*.md 加载)、build_system_prompt()(拼装)、describe()(查看加载情况)。/skills 命令列出当前加载了哪些技能文件。mini_agent/prompt.py 的 BASE_PROMPT,固定的行为约定放在这里。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. """ |
environment_block 读取当前目录、平台和日期,拼成环境信息。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" ) |
load_skills 读取 skills 目录中的 Markdown 文件,skills_block 拼接正文。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" |
build_system_prompt 按骨架、环境、技能的顺序拼接。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) |
App.__init__ 加载技能并构造 system_prompt,run_turn 将它传给 agent_loop。App.handle_command 中的 /skills 分支显示已加载的技能。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() |
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)) |