← 返回

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 启动时序图

tau-startup-sequence.png

进入 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_agenttau_coding 同时 import 两者;没有任何代码 import tau_coding

tau-layered-architecture.png

三个值得停一下的设计点:

  • 契约倒置。 供应商契约 ModelProvider 不是定义在适配层,而是定义在最底层的 tau_agent/provider.py:19,用的是 typing.Protocol——适配器不需要继承任何基类,结构上满足即可。harness 只认识这个 Protocol,对 Anthropic 还是 OpenAI 一无所知。
  • AGENTS.md 里的 “AgentSession” 落地后叫 CodingSessionsession.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:44run_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.providerconfig.model——改两个字段,下一轮就换模型了。

tau-provider-switching.png

3.1 目录是数据,不是代码

src/tau_coding/data/catalog.toml 里登记了 28 个内置供应商,但只有 5 种 ProviderKindprovider_catalog.py:11-28)。绝大多数"供应商"——DeepSeek、Moonshot、OpenRouter、xAI……——本质上是同一个 kind(openai-compatible)配上不同的 base_urlapi_key_env 和模型清单。每个模型还能带 model_metadata,比如单独指定走 completions 还是 responses API,所以同一供应商下两种传输混用也不需要新代码。

目录还有一层用户覆盖:effective_catalog() 会把 ~/.tau/catalog.toml 合并到内置目录之上(catalog_loader.py:133),想接私有网关不用改源码,tau setup 命令也是往这里写。

3.2 配置与凭证

ProviderSettingsprovider_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 / --modelresolve_provider_selection() 解析并校验 model ∈ provider.modelsprovider_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 只多了一个消息类型和一种会话条目。整张流程图:

tau-compaction-flow.png

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 tokencontext_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_compactsession.py:1837)里:任何异常只记日志、返回 False。压缩是 best-effort,绝不能让一次失败的摘要丢掉用户的一轮对话。

4.3 策略:留尾巴 + 结构化摘要 + 滚动合并

自动压缩不压缩全部历史,而是留一条尾巴(session.py:2032-2050):

  1. 从最新的消息往回累计估算 token,攒够约 20,000 就停(DEFAULT_COMPACTION_KEEP_RECENT_TOKENS);
  2. 切点再对齐到下一条 user 消息边界——保证留下的尾巴从一轮完整对话开始,不会把半个工具调用切出来;
  3. 如果算下来没有可替换的旧消息,直接放弃这次压缩。

摘要由当前供应商、当前模型完成(没有单独配便宜模型),不带工具,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.mddev-notes/hugging-face-model-parity.md 讲了目录与供应商层的一些边界决策。