Tau 内部原理:从一条命令到上下文压缩
复制为 Markdown本文由 KIMI K3 结合开源项目源码总结而成,仅供学习使用。
开源地址:https://github.com/huggingface/tau
Tau 是 Pi 极简 coding-agent 架构的 Python 移植(github.com/huggingface/tau)。这份笔记自下而上读了一遍代码,回答四个问题:tau 命令怎么启动、代码怎么分层、多模型供应商怎么接入和切换、上下文压缩怎么工作。文中引用均为 文件:行号,可直接跳过去对照。
1. 终端里敲一个 tau,发生了什么
1.1 shim 从哪来
tau 不是项目里手写的脚本,而是 Python 打包的标准机制。pyproject.toml:30 声明了 console script:
[project.scripts]
tau = "tau_coding.cli:app"
官方安装脚本(website/static/install.sh / install.ps1)做的事很简单:先找到(或装上)uv,然后执行 uv tool install tau-ai。uv 会为 PyPI 包建一个隔离环境,并按照上面的声明在 ~/.local/bin(Windows 下是 %USERPROFILE%\.local\bin)生成 tau shim。这个 shim 干的事等价于一行 sys.exit(app())。
app 是一个 Typer 实例(src/tau_coding/cli.py:90)。顺带一提,模块 import 时就执行了 _force_utf8_streams()(cli.py:74),专门解决 Windows 控制台的编码问题。
1.2 一个 main() 吃掉所有参数
cli.py 里没有注册任何 Typer 子命令。一个 @app.callback(invoke_without_command=True) 的 main()(cli.py:135)解析全部 flag,update / sessions / export / providers / setup 这几个"子命令"是从位置参数里手动分发的。最关键的分支在 cli.py:313:
print_requested = print_mode or mode is not None # --print 或 --mode 都算管道模式
之后两条路殊途同归——都用 anyio.run() 进入异步世界,都装配同一个 CodingSession,区别只在最后一公里的呈现:
main() # cli.py:135
├── --version / 手动子命令 → 直接处理掉
├── TUI 模式(默认)
│ anyio.run(run_openai_tui, ...) # cli.py:357
│ → load_provider_settings() # 读 ~/.tau 下的供应商配置
│ → create_model_provider(...) # 造出具体供应商客户端
│ → CodingSession.load(config) # session.py:297,见下
│ → TauTuiApp(session).run_async() # Textual 接管
└── print 模式(--print / --mode)
anyio.run(run_openai_print_mode, ...) # cli.py:648
→ 同样建 session
→ async for event in session.prompt(prompt):
renderer.render(event) # text / json / transcript 三种渲染器
CodingSession.load()(session.py:297)是启动期的组装车间:读 JSONL 会话文件重放成内存状态 → 加载 skills / 提示词模板 / AGENTS.md → 挂扩展 → create_coding_tools() 建四个内置工具 → 拼系统提示词 → 最后构造出那个"大脑":
harness = AgentHarness(
AgentHarnessConfig(provider=config.provider, model=..., system=system, tools=tools),
messages=state.messages,
) # session.py:367
1.3 启动时序图

进入 TUI 之后,每次回车提交都跑在一个 Textual worker 里:async for event in self.session.prompt(text) 一路消费事件流,交给 TuiEventAdapter 转成界面状态(tui/app.py:4014)。也就是说交互模式和管道模式共享同一条事件总线,只是末端接的"显示器"不同。
2. 三层架构:tau_coding → tau_ai → tau_agent
AGENTS.md 里写的三层划分不是口号,import 方向可以验证:tau_agent 只依赖 pydantic 和标准库;tau_ai 只 import tau_agent;tau_coding 同时 import 两者;没有任何代码 import tau_coding。

三个值得停一下的设计点:
- 契约倒置。 供应商契约
ModelProvider不是定义在适配层,而是定义在最底层的tau_agent/provider.py:19,用的是typing.Protocol——适配器不需要继承任何基类,结构上满足即可。harness 只认识这个 Protocol,对 Anthropic 还是 OpenAI 一无所知。 - AGENTS.md 里的 “AgentSession” 落地后叫
CodingSession(session.py:240)。分工很干脆:AgentHarness管内存里的"脑"(消息历史、事件订阅、插话队列、打断令牌),CodingSession管"环境"(持久化、压缩、命令、扩展、重试)。 - UI 只消费事件。 harness 全程不 print、不碰 Rich/Textual,只
yield事件。TUI 用TuiEventAdapter把事件翻译成界面状态,print 模式用EventRenderer协议(text/json/transcript 三个实现)。tau_coding/events.py还在AgentEvent之上叠了会话级事件(压缩开始/结束、自动重试、队列更新),应用层的破事不往底层渗。
2.1 agent loop 本体
核心循环在 src/tau_agent/loop.py:44 的 run_agent_loop(),是一个纯异步生成器。主干(loop.py:113-166)翻成伪代码:
async def run_agent_loop(provider, model, system, messages, tools, ...):
while True: # 一个 while = 一个 turn
assistant = None
async for event in provider.stream_response(
model=model, system=system, messages=messages, tools=tools):
yield event # 流式事件原样转发给订阅者
if event 是完整的 assistant 消息:
assistant = event.message
messages.append(assistant)
if assistant.stop_reason in {"error", "aborted"}:
return # 出错或被打断,结束整个 agent
for call in assistant.tool_calls: # 按顺序执行工具调用
async for event in 执行单个工具(call):
yield event # tool_execution_start/update/end
if event 是工具结果:
messages.append(结果) # 立刻喂回上下文
if 没有工具调用 and 插话队列是空的:
break # 模型说完了,没人插话,收工
两个细节:一是工具是隔离边界,tool.execute() 外面包着 except Exception,异常被包成错误结果消息返回给模型,loop 自己永不崩(loop.py:285-303);二是每轮结束会排干 steering/follow-up 队列,所以用户在模型干活时敲的话不会丢,会变成下一轮的一部分。
3. 多供应商:注册表 + 工厂 + 可替换的策略
支持一堆供应商这件事,tau 拆成了三段:目录(数据)→ 配置(持久化)→ 工厂(运行时)。换来换去的核心依据只有一个事实:AgentHarness.config 是个普通可变 dataclass,loop 每一轮都现读 config.provider 和 config.model——改两个字段,下一轮就换模型了。

3.1 目录是数据,不是代码
src/tau_coding/data/catalog.toml 里登记了 28 个内置供应商,但只有 5 种 ProviderKind(provider_catalog.py:11-28)。绝大多数"供应商"——DeepSeek、Moonshot、OpenRouter、xAI……——本质上是同一个 kind(openai-compatible)配上不同的 base_url、api_key_env 和模型清单。每个模型还能带 model_metadata,比如单独指定走 completions 还是 responses API,所以同一供应商下两种传输混用也不需要新代码。
目录还有一层用户覆盖:effective_catalog() 会把 ~/.tau/catalog.toml 合并到内置目录之上(catalog_loader.py:133),想接私有网关不用改源码,tau setup 命令也是往这里写。
3.2 配置与凭证
ProviderSettings(provider_config.py:332)持久化在 ~/.tau/providers.json:默认供应商、各供应商的 ProviderConfig、快捷循环的 scoped models。凭证解析按 OAuth token → 密钥库 → 环境变量的顺序找(provider_config.py:1549);OAuth 场景下会往配置里塞一个 credential_resolver 回调,每次发请求前现场刷新 token(provider_runtime.py:238-269),避免拿着过期 JWT 撞墙。
3.3 工厂按模型分发
create_model_provider()(provider_runtime.py:50)先按配置类型分发,再按单个模型的 api 元数据分发:
if selected_api == "anthropic-messages": return AnthropicProvider(cfg)
if selected_api == "google-generative-ai": return GoogleGenerativeAIProvider(cfg)
if selected_api == "mistral-conversations": return MistralConversationsProvider(cfg)
return OpenAICompatibleProvider(cfg) # completions / responses 再看模型
所有适配器的出口都汇到同一个函数 canonicalize_provider_stream()(tau_ai/stream.py:88):各家解析器先产内部事件,统一归一化成 Pi 兼容的事件流,连 finish reason 都在这里对齐(toolUse/length/stop)。上游 loop 因此只用面对一种事件方言。
3.4 切换的三种姿势
- 启动时:
--provider/--model经resolve_provider_selection()解析并校验model ∈ provider.models(provider_config.py:1246)。TUI 启动时的优先级是:显式 flag → 恢复会话记录的供应商 → 有可用凭证的默认供应商 → 第一个有凭证的供应商(tui/app.py:5937)。 - 会话内:
/model <名字>直接换;裸/model弹出ModelPickerScreen,列出所有"有凭证可用"的 (provider, model) 组合。跨供应商切换走_set_provider_model()(session.py:944-986):
def _set_provider_model(self, provider_config, model, ...):
provider = create_model_provider(provider_config, model=model, ...) # 造新客户端
self._owned_providers.append(provider) # 旧客户端攒着, 会话关闭时统一 aclose()
self._harness.config.provider = provider # ← 关键两行
self._harness.config.model = model
self._persist_default_model_choice() # 写回 providers.json, 成为新默认
# messages / tools / session id 一概不动
- 下次启动:选择已写盘,
tau --session <id>恢复时也会按会话记录还原供应商和模型。
模式小结:Protocol 是策略(结构化满足,零继承),catalog 是注册表(数据驱动 + 用户覆盖),create_model_provider 是工厂,五个供应商类是适配器,canonicalize_provider_stream 是桥接,斜杠命令返回 CommandResult 标志位由 TUI 解释、命令层不碰 Textual——命令模式。
4. 上下文压缩:阈值触发,LLM 摘要,滚动合并
压缩完全是 tau_coding 的事,底层 harness 只多了一个消息类型和一种会话条目。整张流程图:

4.1 阈值:不是百分比,是"窗口减预留"
触发线沿用 Pi 的算法(context_window.py:166):阈值 = 上下文窗口 − 16,384。128k 窗口对应 111,616。生效阈值按优先级取(session.py:657-667):CLI 的 --auto-compact-threshold > 供应商运行时上报的限制(比如 Codex 系的 90% 窗口默认,model_limits.py:34-39)> 窗口减预留。
估算本身是故意粗糙的:字符数 ÷ 4 + 每条消息 4 token + 每个工具 16 token(context_window.py:17-23),结果带缓存,transcript 一变就失效。不追求准,追求的是每轮检查都便宜。注意 --auto-compact-threshold 只接进了 TUI 模式,print 模式不吃这个 flag。
4.2 时机:每轮两次,外加报错兜底
检查点在每轮 prompt 之前、prompt 完成之后、continue_() 之后(session.py:1555 / 1662 / 1702)。第三条路是供应商直接报上下文溢出:is_context_overflow_error 按错误文本里的特征串(“context length”、“too many tokens” 等)识别,命中后先压缩、再用 harness.continue_() 自动重试一次(session.py:1591-1661)——只有这条路会真正发出 CompactionStartEvent/CompactionEndEvent 事件,阈值触发的自动压缩是静默跑的。
所有自动压缩都包在 _try_auto_compact(session.py:1837)里:任何异常只记日志、返回 False。压缩是 best-effort,绝不能让一次失败的摘要丢掉用户的一轮对话。
4.3 策略:留尾巴 + 结构化摘要 + 滚动合并
自动压缩不压缩全部历史,而是留一条尾巴(session.py:2032-2050):
- 从最新的消息往回累计估算 token,攒够约 20,000 就停(
DEFAULT_COMPACTION_KEEP_RECENT_TOKENS); - 切点再对齐到下一条 user 消息边界——保证留下的尾巴从一轮完整对话开始,不会把半个工具调用切出来;
- 如果算下来没有可替换的旧消息,直接放弃这次压缩。
摘要由当前供应商、当前模型完成(没有单独配便宜模型),不带工具,system prompt 是专门的"你只输出结构化摘要"(session.py:1971-2002)。摘要格式是 Pi 定死的六段式:## Goal / ## Constraints & Preferences / ## Progress(Done / In Progress / Blocked)/ ## Key Decisions / ## Next Steps / ## Critical Context,并要求原样保留文件路径、函数名、报错信息(context_window.py:34-60)。
反复压缩时是滚动合并而不是推倒重来:如果待压缩历史已经以上一轮摘要开头(Previous conversation summary: ),就把它抽进 <previous-summary> 标签,换用 UPDATE 提示词让模型把新消息合并进旧摘要(context_window.py:62-93)。手动 /compact 有两个不同:不留尾巴、全量摘要,且可以带自定义指令(作为 Additional focus: 拼进提示词)。
主干逻辑(session.py:1956-1969):
async def _maybe_auto_compact(self) -> bool:
threshold = self.auto_compact_token_threshold # 见 4.1 的优先级
if threshold is None or self.context_token_estimate <= threshold:
return False
plan = self._recent_preserving_compaction_plan() # 留 ~20k 尾巴 + user 边界对齐
if plan is None:
return False # 没啥可压的
summary = await self._generate_compaction_summary(plan.messages_to_summarize)
await self._append_compaction(summary, replace_entry_ids=plan.replace_entry_ids)
return True
4.4 落盘:只追加,不改写
压缩结果不是"删掉旧消息",而是往 JSONL 会话文件追加一个 CompactionEntry(summary, replaces_entry_ids) 再加一个 LeafEntry 指针(session.py:2055-2077)。重放文件时,SessionState 把 id 落在 replaces_entry_ids 里的消息从活动上下文摘除,在原来第一条的位置插一条摘要 UserMessage(tau_agent/session/memory.py:106-129),然后 harness.replace_messages() 刷新内存。于是压缩后的下一轮请求长这样:
system: <系统提示词>
user: Previous conversation summary:
## Goal ... ## Progress ... ## Key Decisions ...
<保留的最近 ~20k token 原始消息, 从一条 user 消息开始>
原始消息永远留在文件里——会话统计会把被替换的消息也算进累计用量,TUI 里摘要渲染成可折叠条目(Ctrl+O 展开),/tree 分支浏览时压缩只作用于它所在的分支路径。
5. 速查表
| 主题 | 位置 |
|---|---|
| console script 声明 | pyproject.toml:30 |
| CLI 入口 / TUI 与 print 分支 | src/tau_coding/cli.py:135 / :313 |
| TUI 启动装配 | src/tau_coding/tui/app.py:6024 |
| CodingSession 装配 / prompt | src/tau_coding/session.py:297 / :1499 |
| AgentHarness / agent loop | src/tau_agent/harness.py:61 / src/tau_agent/loop.py:44 |
| ModelProvider 契约 | src/tau_agent/provider.py:19 |
| 内置供应商目录 / 用户覆盖 | src/tau_coding/data/catalog.toml / ~/.tau/catalog.toml |
| 供应商工厂 | src/tau_coding/provider_runtime.py:50 |
| 会话内换模型 | src/tau_coding/session.py:944 |
| 压缩阈值 / 常量 | src/tau_coding/session.py:657 / src/tau_coding/context_window.py:17-23 |
| 压缩触发 / 留尾巴计划 | src/tau_coding/session.py:1956 / :2032 |
| 摘要提示词(新建 / 增量) | src/tau_coding/context_window.py:34 / :62 |
| CompactionEntry 重放 | src/tau_agent/session/memory.py:106 |
想继续深挖的话,dev-notes/architecture/phase-22-compaction-foundation.md 记录了压缩落地的设计过程,dev-notes/catalog-model-safety.md 和 dev-notes/hugging-face-model-parity.md 讲了目录与供应商层的一些边界决策。
本站文章遵循 Apache 2.0 开源协议,转载请注明出处,商业化出版请联系本站管理员(页面底部)
鄂公网安备42098402000265号