Pico 学习笔记(2026-09-22):Harness 设计、历史压缩、三类目录与缓存复用
主题:Harness 是什么 → 长历史怎么进上下文 → 相关笔记怎么召回 → sessions/runs/memory 三者的区别 → 短期工作记忆存哪 → prefix 缓存复用。
标记说明:
【补充】= 在原文基础上扩展的内容;【更正】= 与源码不符、已按代码修正。
〇、写之前先想清楚的问题
这是我给自己定的"老规矩",也是这份笔记的出发点:
| # | 要点 | 一句话理解 |
|---|---|---|
| 1 | 全链路思维 + 架构设计图 | 拿到一个项目,先画整体架构图,别一头扎进函数 |
| 2 | 注重生产级异常处理和兜底 | 正常路径谁都会写,值钱的是出错时怎么办 |
| 3 | 锻炼 AI 协作能力、需求理解能力、方案设计能力 | 这三个能力比敲代码更难被替代 |
| 4 | 为什么这么设计、如何排错 | 真正值钱的是设计思维和排查能力,不是"我写了什么" |
| 5 | 先想方案设计,再写代码 | 不是一上来就写代码 |
| 6 | 先从 harness 项目入手,拆解模块 | 不是一上来就学全链路技术栈;遇到不会的再针对性补 |
| 7 | 主动提自己踩过的坑 | 踩坑的价值 >> 实现的价值 |
| 8 | 你做了什么、解决了什么、为什么这么设计 | 三件套,缺一个都讲不完整 |
| 9 | 多问自己为什么 | 一个点能经得住多次 why,那就值得写 |
| 10 | 项目准备到 70% 就可以开始投递 | 让市场检验你的不足,面试本身就是学习的一环 |
一、Harness 是如何设计的
1.1 一句话定义
Agent = LLM + Harness。
- 大模型负责:拿到信息、识别意图、决定下一步做什么。
- Harness 负责:规定在什么边界内做、怎么记录、怎么验证、什么时候才算真的完成。
所以 Harness 是**"让 LLM 在一个受控环境里跑起来"的系统**。
类比:模型是司机 (会看路、会判断),Harness 是车本身------方向盘打多少度有极限、油表要记录、刹车必须有、到没到目的地得有人判定。没有车,司机再聪明也走不了。
1.2 Pico 的四层设计
| 层 | 解决的问题 | 对应模块 |
|---|---|---|
| 工具治理 | 不是看到工具调用就直接跑 | 先走白名单 → 检查工具是否存在 → 参数是否合法 → 是不是重复调用 |
| memory + context manager | 当前 prompt 里到底什么该进、什么不该进 | context_manager.py / features/memory.py |
| run artifacts | 过程和结果都要留下来 | 既能复盘,也能评测 |
| benchmark | 有了回归能力,改动才敢做 | 12 组上下文矩阵 / 12 memory 任务 / 10 恢复场景 / 12 回归任务 |
1.3 为什么叫它 Harness
把这几层合起来看,我想解决的是:能不能把一个 code agent 做成一个有状态、有边界、有验证、也有回归能力的工程系统。
这也是我最后把 Pico 定义成 harness 而不是"又一个 agent demo"的原因。
二、对话历史很长时,每轮怎么处理进入上下文的内容
ContextManager 面对长历史时,遵循三条原则:近窗优先 + 旧史压缩 + 从新到旧逐条加入。
| 原则 | 做法 |
|---|---|
| 1. 近窗优先 | 最近 6 条历史完整保留 ------ 下一步决策依赖刚刚发生的事 |
| 2. 旧史压缩 | 更早的内容做压缩:重复内容算一次、有摘要的用摘要、工具输出生成摘要行 |
| 3. 从新到旧逐条加入 | 逐条往预算里塞,直到超预算为止 |
2.1 超预算了怎么兜底
直接把整个原始历史截断到预算 ------ 这是最后的保险。
2.2 【补充】代码里的具体参数
| 位置 | 内容 |
|---|---|
context_manager.py:_render_history_section() |
recent_window = 6(第 316 行),recent_start = len(history) - 6 |
| 近窗条目 | _render_history_item(item, 900) ------ 单条最多 900 字符 |
更早的 read_file |
同路径只保留第一次 (collapsed_duplicate_reads);若 memory 里有该文件的摘要,直接渲染成 路径 -> 摘要 一行(reused_file_summary_count) |
| 更早的其他工具 | 走 _summarize_old_tool_item() 生成摘要行(summarized_tool_count) |
| 更早的其他消息 | _render_history_item(item, 60) ------ 只留 60 字符 |
| 近窗也超预算 | 近窗条目按剩余空间截断(不是直接丢),保证"最近发生的事"至少留个尾巴 |
| 最终兜底 | if len(rendered) > budget: rendered = _tail_clip(history_raw, budget) |
细节 :
_tail_clip(text, limit)的实现是text[:limit-3] + "..."------ 它保留的是文本开头 。所以最后这道保险截出来的是历史的开头(最旧的部分),不是最近的。命名和实际行为有点反直觉,如果面试被问到"最后兜底保的是哪段",这是个能加分的小点。
2.3 【补充】还有第二个历史渲染器
runtime.py:history_text()(第 239 行)是另一处渲染逻辑,同样用 recent_start = len(history) - 6,只是近窗给 900、旧窗给 180。
两处窗口大小一致(都是 6),说明 6 是刻意选定的常量而不是随手写的数。
三、相关笔记的检索流程是怎么样的
3.1 流程
对当前用户请求分词后,遍历短期记忆(episodic_notes)和长期记忆(durable memory),按「标签命中 > 关键词重叠 > 时间新旧」排序,最终取前 3 条作为相关笔记。
3.2 这么粗糙的召回,会出什么问题
| 场景 | 例子 | 问题 |
|---|---|---|
| 1. 关键词碰巧重叠 | 当前任务"帮我修复登录 bug" / 召回笔记"用户登录过 GitHub,token 是 xxx" | 都含"登录",但完全不相关 |
| 2. tag 太宽泛 | 当前任务"数据库配置在哪" / 召回笔记"database migration decided in 2025" | tag 都是 database,但一个是"找配置",一个是"历史决策" |
| 3. 长期记忆过期 | 当前任务"项目现在用什么数据库" / 召回笔记"Project uses MySQL" | 半年前已迁到 PostgreSQL,旧笔记还在 |
| 4. 主语误匹配 | 当前任务"项目用什么语言" / 召回笔记"Project uses PostgreSQL" | 主语都是 project,说的却是不同维度 |
3.3 为什么明知粗糙还是这么做
因为 Pico 现在更关注可解释性。 用 tag、关键词、时间新旧这三个维度,可以直观解释"这条为什么被召回",用于复盘和审计。
类比:这就像用明确的筛选条件("标签是 database")找人,而不是用"长得像"来找。前者你能说清楚为什么选中它,后者选错了你都不知道错在哪。
3.4 后续改进方向
- 加关键词重叠阈值 ------ 低于一定阈值的不召回;
- 加语义相似度召回。
3.5 【补充】排序键的精确形式与两级过滤
排序键是一个四元组,降序取 top-N:
(exact_tag_match, keyword_overlap, recency, note_index)
| 字段 | 含义 |
|---|---|
exact_tag_match |
query 分词与笔记 tags 是否有交集(0/1)。这是最硬的一级 |
keyword_overlap |
query 分词与笔记正文/source/tags 分词的重叠个数 |
recency |
created_at 解析成时间戳,越新越大 |
note_index |
笔记序号,打破平局用 |
并且有一道硬过滤 :if exact_tag_match == 0 and keyword_overlap == 0: continue ------ 两者全为 0 直接跳过,不参与排序。
3.6 【更正 + 重要缺陷】中文提问召不回任何笔记
这道"硬过滤"加上分词实现,产生了一个实测可复现的缺陷。
分词函数(features/memory.py:282):
python
def _tokenize(text):
return {token.lower() for token in re.findall(r"[A-Za-z0-9_]+", str(text))}
它只认 [A-Za-z0-9_]。 于是:
| 输入 | 分词结果 | 召回 |
|---|---|---|
帮我改一下权限模块的登录校验 |
空集 set() |
0 条 |
update policy.py login check |
{update, policy, py, login, check} |
1 条 |
根因链条 :中文 query → _tokenize 返回空集 → exact_tag_match = 0 且 keyword_overlap = 0 → 命中 continue → 所有笔记都被跳过。
影响 :纯中文输入下,relevant_memory 段恒为 - none------除非 query 里恰好带了 ASCII(文件名、函数名、变量名)。
修法(尚未实施):加中文 n-gram,或接 jieba 分词。
这个缺陷很适合放进面试复盘:不是算法不行,是分词口径没覆盖中文。一句话就能体现"我真的跑过、真的定位到根因了"。
四、sessions 和 runs 和 memory 有什么区别
假设你打开 Pico,进行了三次对话:
第1次:pico "帮我看看 README"
第2次:pico "再帮我看看 config.py"
第3次:pico --resume latest "把数据库改成 MySQL"
4.1 sessions:对话历史,用于恢复
存什么:
json
{
"id": "session_20260929_143022",
"messages": [
{"role": "user", "content": "帮我看看 README"},
{"role": "assistant", "content": "README 说项目用 PostgreSQL"},
{"role": "user", "content": "再帮我看看 config.py"},
{"role": "assistant", "content": "config.py 里有数据库连接串"},
{"role": "user", "content": "把数据库改成 MySQL"},
{"role": "assistant", "content": "已修改 config.py"}
],
"created_at": "2026-09-29T14:30:22",
"updated_at": "2026-09-29T15:10:05"
}
特点:
- 跨轮次:三次对话都在同一个 session 里
- 跨进程 :关掉 Pico 再打开,
--resume latest能接着聊 - 完整事件流:所有 user / assistant / tool 消息
作用:
- 让模型知道"之前聊过什么"
- 支持
--resume latest恢复对话
文件位置:
.pico/sessions/
└── session_20260929_143022.json
4.2 runs:单次运行档案,用于复盘审计
存什么:
.pico/runs/
├── run_20260929-143022-abc123/ ← 第1次 ask
│ ├── task_state.json ← 任务状态
│ ├── report.json ← 报告
│ └── trace.jsonl ← 追踪日志
├── run_20260929-143500-def456/ ← 第2次 ask
│ ├── task_state.json
│ ├── report.json
│ └── trace.jsonl
└── run_20260929-151005-ghi789/ ← 第3次 ask
├── task_state.json
├── report.json
└── trace.jsonl
task_state.json 内容:
json
{
"run_id": "run_20260929-143022-abc123",
"task_id": "task_001",
"user_request": "帮我看看 README",
"status": "completed",
"stop_reason": "final_answer_returned",
"attempts": 2,
"tool_steps": 1,
"last_tool": "read_file",
"final_answer": "README 说项目用 PostgreSQL",
"checkpoint_id": "ckpt_001",
"resume_status": "no-checkpoint"
}
trace.jsonl 内容(一行一个事件):
{"event": "run_started", "created_at": "..."}
{"event": "prompt_built", "created_at": "...", "duration_ms": 1284}
{"event": "model_requested", "created_at": "..."}
{"event": "model_parsed", "created_at": "...", "duration_ms": 12}
{"event": "tool_executed", "created_at": "...", "duration_ms": 3}
{"event": "checkpoint_created", "created_at": "...", "trigger": "tool_executed"}
{"event": "run_finished", "created_at": "...", "run_duration_ms": 1310}
特点:
- 每次 ask 一个 run:三次对话 → 三个 run
- 完整运行档案:状态 + 追踪 + 报告
- 不可变:run 一旦结束,就不再修改
作用:
- 复盘:看某次运行发生了什么
- 评测:benchmark 聚合多个 run
- 调试:定位某次运行的问题
4.3 【更正】上面这两段有 4 处与源码不符
笔记最初是凭印象写的,对着代码逐条核了一遍:
| # | 原笔记写的 | 源码实际 | 依据 |
|---|---|---|---|
| 1 | 文件名 trace.log |
trace.jsonl |
run_store.py:30 trace_path() |
| 2 | 事件字段 "event_type" |
"event" |
runtime.py:342 payload["event"] = event |
| 3 | task_state 里有 "created_at" |
没有这个字段 ;实际还有 last_tool / checkpoint_id / resume_status |
task_state.py:97 to_dict() |
| 4 | "status": "success" |
"completed" (常量 STATUS_COMPLETED) |
task_state.py:12 |
另外 run 目录名用短横线 分隔:run_YYYYMMDD-HHMMSS-<6位hex>(task_state.py:44),不是下划线。
为什么值得较真:这些字段名在面试里被追问的概率不低("你怎么区分'怎么停的'和'停下时什么状态'?")。答错字段名,前面讲得再好也会被扣分。
顺带一个设计点:
status和stop_reason是分开存的两个字段 ------status说"停下时是什么状态"(running / completed / stopped / failed),stop_reason说"怎么停的"(final_answer_returned / step_limit_reached / retry_limit_reached / model_error / tool_timeout / approval_denied / delegate_failed / persistence_error / resume_load_error)。
4.4 memory:长期记忆
存什么:
.pico/memory/
├── MEMORY.md ← 索引
└── topics/
├── project-conventions.md ← 项目约定
├── key-decisions.md ← 关键决策
├── dependency-facts.md ← 依赖事实
└── user-preferences.md ← 用户偏好
特点:
- 跨会话:关掉 Pico 再打开,记忆还在
- 稳定事实:只存长期有效的信息
- 按主题分类:4 个固定主题
作用:
- 让模型"记住"项目长期事实
- 减少重复读取文件
- 跨会话保持一致性
4.5 【补充】"什么时候写进 memory"比想象中严格
原笔记写的是"任务成功后,从对话中提炼"。源码里其实有两道门:
第一道:意图门。 用户请求里必须出现意图词才会尝试沉淀(runtime.py:36-37):
| 语言 | 触发词 |
|---|---|
| 英文 | capture / remember / save / store / persist / note |
| 中文 | 记住 / 保存 / 记录 / 沉淀 / 长期记忆 / 持久记忆 |
第二道:格式门。 命中的行要匹配预设的行前缀才会被归类(runtime.py:38-46):
| 主题 | 行前缀(英文 / 中文) |
|---|---|
project-conventions |
Project convention: / 项目约定: |
key-decisions |
Decision: / 决策: |
dependency-facts |
Dependency: / 依赖: |
user-preferences |
Preference: / 偏好: |
还有拒绝规则 (reject_durable_reason()):
| 拒绝原因 | 触发条件 |
|---|---|
empty |
内容为空 |
secret_shaped |
出现 api_key / token / secret / password 这类词,或 sk-xxxxxx 形态的字符串 |
transient_task_state |
行以"当前目标 / 当前卡点 / 下一步 / 当前阶段 / 关键文件 / 已完成 / 已排除"开头 ------ 这是任务瞬时状态,不该进长期记忆 |
noisy_output |
含 stdout / stderr / traceback / exit_code,或长度 > 220 |
还有一处需要更正 :promote_durable_memory() 不只在成功时调用------
| 调用点 | 时机 |
|---|---|
agent_loop.py:99 |
正常拿到 final answer 时 |
agent_loop.py:303 |
因 step limit / retry limit 停下的收尾分支 |
两条路径都会调。 所以准确说法是"每次 run 结束时都尝试沉淀",而不是"只有任务成功才沉淀"。
secret_shaped这条挺值得说 ------ 长期记忆是跨会话持久化的,一旦把 token 写进去就永久留在磁盘上了。所以在"写入口"就拦一道,比事后清理靠谱得多。
五、三者对比
| 维度 | sessions | runs | memory |
|---|---|---|---|
| 存什么 | 对话历史 | 单次运行档案 | 长期记忆 |
| 粒度 | 会话级 | 任务级 | 事实级 |
| 生命周期 | 跨轮次、跨进程 | 单次 ask | 跨会话 |
| 写入时机 | 每轮对话 | 每次 ask | 每次 run 结束(需过意图门 + 格式门) |
| 是否可变 | 持续追加 | 不可变 | 可更新(同主题旧值被 supersede) |
| 内容类型 | user / assistant / tool 消息 | 状态 + 追踪 + 报告 | 稳定事实 |
| 大小 | 可能很大 | 中等 | 精简 |
| 是否进 Prompt | 部分(近窗 6 条 + 压缩后的旧窗) | 否 | 按需召回(最多 3 条) |
| 谁在用 | 模型 | 开发者 | 模型 |
一个容易忽略的点 :
runs完全不进 prompt。它只是"事后的证据",模型看不到。要进 prompt 的只有 sessions 的窗口和 memory 的召回结果。
六、三者的关系
用户对话
│
▼
┌─────────────────────────────────────────────┐
│ sessions(对话历史) │
│ │
│ 记录所有 user/assistant/tool 消息 │
│ 跨轮次、跨进程 │
└─────────────────────────────────────────────┘
│
│ 每次 ask 产生一个 run
▼
┌─────────────────────────────────────────────┐
│ runs(运行档案) │
│ │
│ 记录单次运行的状态、追踪、报告 │
│ 每次 ask 一个 run │
└─────────────────────────────────────────────┘
│
│ run 结束时,按规则提炼关键信息
▼
┌─────────────────────────────────────────────┐
│ memory(长期记忆) │
│ │
│ 记录稳定的事实、决策、偏好 │
│ 跨会话持久化 │
└─────────────────────────────────────────────┘
数据流:
- 用户对话 → 写入 sessions
- 每次 ask → 产生一个 run
- run 结束 → 按规则提炼 → 写入 memory
七、为什么要分三个目录
7.1 职责分离
| 目录 | 职责 | 谁用 |
|---|---|---|
| sessions | 对话连续性 | 模型(看历史) |
| runs | 复盘和评测 | 开发者(看过程) |
| memory | 长期知识 | 模型(看事实) |
7.2 生命周期不同
- sessions:跨轮次,持续追加
- runs:单次 ask,不可变
- memory:跨会话,可更新
7.3 用途不同
- sessions:让模型知道"之前聊过什么"
- runs:让开发者知道"这次运行发生了什么"
- memory:让模型知道"项目的长期事实"
7.4 一句话总结
三次对话 → 一个 session(对话历史)+ 三个 runs(每次运行档案)+ 若干 memory(提炼出的事实)。
补一句源码里的原话(
run_store.py开头注释):"session.json 负责保存'可恢复的会话状态';RunStore 负责保存'单次运行的审计工件'。两者分开后,恢复现场和复盘证据不会混在一起。"这就是这个设计最核心的动机:恢复 和审计是两件不同的事,混在一起会互相干扰。
八、长期记忆存在 .md 里,短期工作记忆存哪
8.1 短期工作记忆(state 字典)
短期工作记忆存 state["working"] 和 state["episodic_notes"]。
python
state = {
"working": {
"task_summary": "当前任务的简短描述", # 最多 300 字符
"recent_files": ["最近接触的文件路径,最多 8 个"],
},
# 跨轮笔记,最多 12 条;每条含 text / tags / source / created_at / note_index / kind
# 渲染时只显示条数,不展开正文
"episodic_notes": [...],
# 每个文件一个摘要,并记录 freshness(sha256),用来判断摘要是否过期
"file_summaries": {...},
}
作用:
- 告诉模型"当前在做什么任务"
- 告诉模型"最近碰了哪些文件"
- 支持相关笔记检索
- 可以提炼为长期记忆
8.2 它存在内存里
- Pico 运行时,这个字典在内存中
- 每轮对话更新它
- 程序退出后,内存释放
内存 vs 硬盘的区别:
| 操作方式 | |
|---|---|
| 内存 | 变量、字典、列表,直接操作 |
| 硬盘 | 文件读写,需要 read_text() / write_text() |
注意:短期工作记忆在物理上也会被写进 session.json (
self.session["memory"] = self.memory.to_dict()),所以--resume之后还能接着用。它"短期"指的是生命周期随会话结束而终止,不是"不落盘"。
8.3 渲染时只给数量,检索时才拿正文
细节:短期记忆渲染时只渲染摘要,不渲染完整正文;正文只在检索时才按需拿出来。
① 渲染时只显示数量 (render_memory_text() 里是 - episodic_notes: {条数})
| 好处 | 说明 |
|---|---|
| prompt 精简 | 不被无关笔记撑爆 |
| 模型有感知 | 看到"有 2 条笔记",知道"有记忆可用" |
| 按需取用 | 具体内容等召回时再说 |
② 检索时按需拿内容
| 好处 | 说明 |
|---|---|
| 只拿相关的 | 不相关的笔记一条都不进 prompt |
| 最多 3 条 | 控制 token |
| 四级排序 | 优先最相关的 |
这套"先给目录、再取内容 "的做法,本质上是给记忆做了一次惰性加载:常驻 prompt 的只是"索引",真正花 token 的是被选中的那几条。
九、缓存复用:PromptPrefix
9.1 生效条件
PromptPrefix 带的三个指纹都不变时,prefix 可复用:
| 指纹 | 判断什么 |
|---|---|
hash |
prefix 文本是否变化 |
workspace_fingerprint |
工作区是否变化 |
tool_signature |
工具是否变化 |
9.2 失效(重建)条件
| 触发条件 | 是否重建 |
|---|---|
| 用户新增文件 | ✅ 重建 |
| 用户修改文件 | ✅ 重建 |
| 用户删除文件 | ✅ 重建 |
| 工具 schema 变了 | ✅ 重建 |
| 首次运行 | ✅ 重建 |
缓存位置 :agent.prefix_state
9.3 收益一:省时间
不缓存:
每轮:扫描目录 + 算指纹 + 渲染工具 + 组装文本 + 算哈希
总计:N × 单轮成本
缓存:
第1轮:扫描目录 + 算指纹 + 渲染工具 + 组装文本 + 算哈希
第2轮起:只检查指纹,不重建 prefix 文本
9.4 【更正】"第 2 轮起只要 5ms"这个说法不成立
原文假设"第 2 轮起只算工作区指纹 = 5ms",但看 refresh_prefix() 的实现,每轮都会重新构建一次 WorkspaceContext (runtime.py:219):
python
def refresh_prefix(self, force=False):
previous_hash = getattr(getattr(self, "prefix_state", None), "hash", None)
previous_workspace_fingerprint = getattr(..., "workspace_fingerprint", None)
# 工作区事实相对稳定,所以这里按整体刷新;
# 只有这些事实真的变化了,才重建完整 prefix。
refreshed_workspace = WorkspaceContext.build(self.root) # ← 每轮都跑
refreshed_workspace_fingerprint = refreshed_workspace.fingerprint()
workspace_changed = force or refreshed_workspace_fingerprint != previous_workspace_fingerprint
...
prefix_state = self.build_prefix() if workspace_changed or force or previous_hash is None else self.prefix_state
而 WorkspaceContext.build() 里要起 5 个 git 子进程 (workspace.py:72-99):
| # | 命令 | 用途 |
|---|---|---|
| 1 | git rev-parse --show-toplevel |
找仓库根 |
| 2 | git branch --show-current |
当前分支 |
| 3 | git symbolic-ref --short refs/remotes/origin/HEAD |
默认分支 |
| 4 | git status --short |
工作区状态(截断到 1500 字符) |
| 5 | git log --oneline -5 |
最近 5 条提交 |
外加读取 README 等项目文档(每份截断到 1200 字符)。
所以准确的说法是 :缓存复用省掉的是"重建 prefix 文本 + 渲染工具说明 + 算文本哈希 "这段;工作区扫描 + 指纹计算每轮照跑,而它才是耗时的大头(在 Windows 上尤其明显,起进程本身就贵)。
这个点如果面试被追问"那缓存到底省了多少",只答"省了重建 prefix"是站得住的;答"从 200ms 降到 5ms"就容易被反问"你测过吗"。实测值应该从 trace 里
prompt_built事件的duration_ms取,那是代码自己记下来的。
9.5 收益二:省 token
如果两次请求的 prompt 前缀完全相同,模型服务端可以复用上次的计算结果,按折扣计费。
请求1:
prompt = [prefix 2000 tokens] + [user 100 tokens]
→ prefix 部分按正常价格计费
→ user 部分按正常价格计费
请求2:
prompt = [prefix 2000 tokens] + [user 150 tokens]
→ prefix 部分命中缓存,按折扣价计费(通常 10%)
→ user 部分按正常价格计费
注意这里是两笔账 :本地复用省的是构建时间 ,服务端 Prompt Cache 省的是钱 。前者靠 hash 一致,后者靠 prompt_cache_key(代码里就直接用 prefix_state.hash)。
9.6 流程总结
Pico 的缓存复用流程是:每次 ask 时刷新工作区、算指纹、对比指纹;如果工作区没变,就复用上次的 prefix;否则重建。复用后,模型服务端能命中 Prompt Cache → 省 Token。
9.7 PromptPrefix 结构
python
@dataclass
class PromptPrefix:
text: str # prefix 文本内容
hash: str # 文本的哈希
workspace_fingerprint: str # 工作区指纹
tool_signature: str # 所有工具信息打包后的哈希值
built_at: str # 构建时间
tool_signature 打包的内容(prompt_prefix.py:22)------ 只挑会进 prompt 的字段:
python
{
"name": name,
"schema": tool["schema"],
"risky": tool["risky"],
"description": tool["description"],
}
这解释了为什么"工具 schema 变了"会导致重建:工具说明本来就被渲染进了 prefix 文本 (
- read_file(path, start, end) [safe] 读取文件内容)。工具变了 → prefix 文本变了 →hash变了 → 服务端缓存失效。
9.8 【补充】prefix 里到底装了什么
prefix 可以理解成 agent 的"工作手册" ,四块内容(prompt_prefix.py:build_prompt_prefix()):
| 块 | 内容 |
|---|---|
| 身份 | You are pico, a small local coding agent working inside a local repository. |
| 规则 | 一次只回一个 <tool> 或 <final>;不要编造工具结果;不要用相同参数重复调同一个工具;必填参数不能为空...... |
| 工具清单 | 每个工具的签名 + 风险标记 + 描述,例如 - read_file(...) [safe] ... / - run_shell(...) [approval required] ... |
| 响应示例 + 工作区快照 | <tool>{...}</tool> / <final>...</final> 的样例;以及 Workspace: 段(cwd / repo_root / branch / status / recent_commits / project_docs) |
这就把前面几节串起来了:
- prefix 里含
workspace段 → 所以工作区变了要重建(第 9.2 节) - prefix 里含工具清单 → 所以工具 schema 变了要重建
- prefix 是"相对稳定的基线" → 所以它适合做缓存和 Prompt Cache 的锚点
- 而
context_manager.py的分层预算里,prefix段拿的是最大的一份(3600 字符,且永不裁到 1200 以下)------ 因为它是最稳定的那部分
十、附:路径符号速查
| 符号 | 含义 |
|---|---|
. |
当前目录 |
.. |
上一级目录 |
../.. |
上两级目录 |
/ |
根目录 |
这几个符号在 Pico 里不只是"读文件时的写法",还是安全边界 :
runtime.py:771的path()会把路径resolve()之后和 workspace 根做os.path.commonpath比对,../显式逃逸会被直接拦下(security_event_type=path_escape)。