10 · 多协议抽象与提示工程
源码:
internal/llm/(provider.go / anthropic.go / openai.go)、internal/config/、internal/prompt/一句话:把两家互不兼容的流式协议收敛成一条事件流,把一段写死的 Prompt 拆成可缓存、可注入、可演进的模块。
一、这一章要回答什么
| 问题 | 本节位置 |
|---|---|
| 为什么要做协议抽象?不做行不行? | §2 |
| Anthropic 和 OpenAI 到底差在哪? | §3.2 |
| 工具调用的 JSON 参数是分片的,怎么拼? | §3.3 |
| Prompt Cache 怎么才能命中? | §5.3 |
| 系统提示为什么要拆模块? | §5.1 |
| 「你在计划模式」这种提醒怎么注入才不破坏缓存? | §5.4 |
二、为什么需要协议抽象
2.1 不做抽象会怎样
Agent 的核心逻辑(ReAct 循环、压缩、TUI)如果要直接调 SDK:
// ❌ 不做抽象:Agent 里到处是协议分支
func (a *Agent) Run(...) {
switch a.protocol {
case "anthropic":
msg := anthropic.MessageNewParams{ ... } // 30 行转换
stream := a.anthropicClient.Messages.NewStreaming(ctx, msg)
for stream.Next() { /* 按 anthropic 事件类型分支 */ }
case "openai":
msg := openai.ChatCompletionNewParams{ ... } // 另一套 30 行转换
stream := a.openaiClient.Chat.Completions.NewStreaming(ctx, msg)
for stream.Next() { /* 按 openai chunk 分支 */ }
}
}问题:① 每加一家 provider,Agent 主循环就多一个分支;② 压缩器也要发 LLM 请求(摘要),它也得写一遍;③ TUI 也要显示 token 用量,它也得认两套 Usage 结构。
2.2 抽象方案
// llm/provider.go:91 —— 只有三个方法
type Provider interface {
Name() string // 状态栏左侧
Model() string // 状态栏右侧
Stream(ctx context.Context, req Request) <-chan StreamEvent // 发起一轮流式对话
}关键设计:错误也走 channel,接口没有 error 返回值。
type StreamEvent struct {
Text string // 文本增量
ToolCalls []ToolCall // 非空:本轮模型请求执行这些工具
Usage *Usage // 非空:本轮 token 用量(Done 之前一次性发出)
Done bool // 本轮正常结束
Err error // 出错
}文档注释里显式定义了五态语义的不变量:
Text与ToolCalls可先后出现但不同时非空;Done/Err互斥且为终结事件。
为什么这么设计:一次流式请求的错误可能发生在连接建立之后、事件流中途(网络抖动、模型服务异常),这与「函数返回值」的时机不匹配。统一走 channel 后,消费端只需要一个 for range + 一个 switch:
// agent.go:492 —— 消费端长这样
for ev := range stream {
switch {
case ev.Err != nil: return ..., ev.Err
case ev.Usage != nil: usage = ev.Usage
case len(ev.ToolCalls) > 0: calls = append(calls, ev.ToolCalls...)
case ev.Text != "": textBuilder.WriteString(ev.Text); emit(...)
}
}代价:SDK 的高级能力被抹平了——thinking block 回传、citations、多模态都进不来(见 §6 的已知缺陷)。
2.3 依赖方向的约束
agent 包注释(agent.go:1-2):
「只依赖 llm、tool、conversation、permission,不 import SDK,保持协议无关。」这条约束在代码里是真实执行的——internal/agent/ 下没有任何 SDK import。这是一个用包注释表达的架构约束,也是重构时的护栏。
三、协议无关的数据模型
3.1 核心类型
// llm/provider.go:44
type Message struct {
Role string // user | assistant | tool
Content string
ToolCalls []ToolCall // 仅 assistant
ToolResults []ToolResult // 仅 tool(一条消息可含多个)
}
// llm/provider.go:76 —— 系统提示拆成两段(缓存策略的载体)
type System struct {
Stable string // 可缓存:不含时间/环境等变化成分
Environment string // 不缓存:每轮可能重建
}
// llm/provider.go:83
type Request struct {
Messages []Message
Tools []ToolDefinition
System System
Reminder string // 本轮 system-reminder(已含标签;空=不注入)
}四个刻意的设计:
RoleTool是自己造的概念——不叫tool_result(Anthropic 的叫法)也不叫function(OpenAI 旧叫法),纯中间层语义。ToolCall.Input是json.RawMessage而不是string——Anthropic 侧直接塞进 SDK,避免「解析成 map 再序列化」造成的 key 顺序变化(会破坏缓存);OpenAI 侧转成 string。System拆两段是缓存策略,不是审美(详见 §5.3)。Reminder单独成字段——因为两家协议的角色交替规则不同,注入位置必须由适配器决定(详见 §5.4)。
3.2 两家协议的差异对照
| 维度 | Anthropic | OpenAI | 本项目的统一表达 |
|---|---|---|---|
| system 结构 | 多个 text block 数组 | 单条 role: system 消息 | System{Stable, Environment} |
| 工具结果位置 | 必须在 user 消息里的 tool_result block | 独立的 role: tool 消息 | RoleTool |
| 一条 tool 消息含多个结果 | ✅ 一个 user 消息放 N 个 block | ❌ 拆成 N 条独立消息 | 内部用 []ToolResult,适配器各自展开 |
| 工具定义 schema | 只吃 properties + required | 整块 JSON Schema 透传 | InputSchema map[string]any |
| 刷新提醒(reminder) | 追加为末条 user 消息的新文本块 | 新增一条尾部 user 消息(容忍连续 user) | Request.Reminder 单字段 |
| 缓存控制 | 显式 cache_control: ephemeral 断点 | 自动前缀缓存,无显式断点 | System.Stable 承载断点 |
| 扩展思考 | 有 thinking 配置(budget_tokens) | 无 | ProviderConfig.Thinking(仅 anthropic) |
| Usage 字段名 | input_tokens / cache_creation_input_tokens / cache_read_input_tokens | prompt_tokens / prompt_tokens_details.cached_tokens / 无 cache write | Usage{Input, Output, CacheWrite, CacheRead} |
| PTL 错误消息 | "prompt is too long" | "context_length_exceeded" | llm.ErrPromptTooLong 哨兵 |
这个表就是「协议抽象到底抽象了什么」的完整答案。面试时能把这张表讲清楚,说明你真的对接过两家 API。
3.3 工具调用参数是怎么拼的
核心事实:拼接不在本项目代码里逐片段做,而是靠 SDK 的 Accumulator。
// anthropic.go:191 —— Anthropic 侧
acc := anthropic.Message{}
for stream.Next() {
evt := stream.Current()
if err := acc.Accumulate(evt); err != nil { ... } // ← input_json_delta 在这里被拼成完整 input
// 文本增量单独上抛(只有 TextDelta 才上抛)
if delta, ok := evt.AsContentBlockDelta(); ok {
if td, ok := delta.Delta.AsAny().(anthropic.TextDelta); ok {
emit(StreamEvent{Text: td.Text})
}
// ThinkingDelta 与 InputJSONDelta 被显式丢弃
}
}
// 流结束后:只有 stop_reason == tool_use 才提取工具调用
if acc.StopReason == anthropic.StopReasonToolUse {
for _, block := range acc.Content {
if tu, ok := block.AsToolUse(); ok && tu.ID != "" {
calls = append(calls, ToolCall{ID: tu.ID, Name: tu.Name, Input: json.RawMessage(tu.Input)})
}
}
emit(StreamEvent{ToolCalls: calls}) // ← 一次性发出,不逐片段
}// openai.go:145 —— OpenAI 侧
acc := openai.ChatCompletionAccumulator{}
for stream.Next() {
evt := stream.Current()
acc.AddChunk(evt) // ← 累积
if len(evt.Choices) > 0 {
if delta := evt.Choices[0].Delta.Content; delta != "" {
emit(StreamEvent{Text: delta})
}
}
}
// 流结束后从 acc 取完整参数
for _, tc := range acc.Choices[0].Message.ToolCalls {
args := tc.Function.Arguments
if args == "" { args = "{}" } // 空参数兜底
calls = append(calls, ToolCall{ID: tc.ID, Name: tc.Function.Name, Input: json.RawMessage(args)})
}面试官追问:为什么
Text增量实时上抛,ToolCalls却要等流结束? 答:因为input_json_delta的片段是非累积的——任意中间时刻拼出来的都是非法 JSON(比如{"pa),上抛没有可用语义。而文本 delta 天然可增量渲染。这是「可用性 vs 实时性」的取舍:代价是工具调用没有打字机效果。替代方案是上抛原始片段由 UI 缓冲,但agent和compact两个消费端都得重新实现拼接,不划算。
四、PTL(上下文过长)的识别与恢复
4.1 靠错误文本子串识别(无奈但务实)
// anthropic.go:274
func wrapAnthropicPTL(err error) error {
if err == nil { return nil }
s := err.Error()
for _, kw := range []string{"prompt is too long", "context_length", "too many tokens"} {
if strings.Contains(s, kw) {
return fmt.Errorf("%w: %v", ErrPromptTooLong, err)
}
}
return err
}
// openai.go:218
func wrapOpenAIPTL(err error) error {
s := err.Error()
if strings.Contains(s, "context_length_exceeded") ||
strings.Contains(s, "maximum context length") ||
strings.Contains(s, "too long") ||
(strings.Contains(s, "token") && strings.Contains(s, "exceed")) {
return fmt.Errorf("%w: %v", ErrPromptTooLong, err)
}
return err
}为什么不用错误码:Anthropic/OpenAI 的 PTL 在不同版本、不同兼容端点下错误类型不稳定,SDK 也未统一暴露 IsContextLengthExceeded() 之类的判定。子串匹配最鲁棒。
代价:脆弱。openai.go 里的 strings.Contains(s, "too long") 相当宽泛,可能误判其它错误。
4.2 恢复链
provider 返回错误(内含 "prompt is too long")
→ wrapXxxPTL 包装成 ErrPromptTooLong(errors.Is 可判定)
→ agent.go:283 判定 errors.Is(sErr, llm.ErrPromptTooLong) && !emergencyRetried
→ compact.ManageContext(Trigger: TriggerEmergency) // 强制 L1 + 强制 L2
→ a.runtime.ResetAnchor() // 历史彻底重写,锚点作废
→ 重新估算,若仍 >= cw - ManualSafetyMargin(3000) → 放弃并报原错
→ 否则 emergencyRetried = true,重发同一次请求(只重试一次)这是一条完整的「检测 → 修复 → 重试 → 放弃」链路,每一环都有明确的退出条件。
五、系统提示工程化
5.1 为什么拆模块
ch04 之前的做法:系统提示是一段揉在一起的固定常量——身份、工具用法、简洁性全挤在一块文本里。问题:① 没有结构;② 想加一类新指令只能往字符串里塞;③ 无法按优先级排序;④ 无法表达「这一段是可选/可变的」。
5.2 模块化装配
// prompt/modules.go:4
type Module struct {
Name string // 模块标识
Priority int // 数值越小越靠前;固定模块 10..70,可选模块 80..100
Content string // 为空则装配时跳过(可选空槽)
}| Priority | 模块 | 内容要点 |
|---|---|---|
| 10 | 身份 | 你是 MewCode,终端 AI 编程助手 |
| 20 | 系统约束 | 文件操作限工作目录、密钥不回显、破坏性操作谨慎 |
| 30 | 任务模式 | ReAct 多步自主循环、编辑前必须先读文件 |
| 40 | 动作执行 | 工具调用策略、只读可并发、有副作用谨慎 |
| 50 | 工具使用 | 优先用专用工具而非 bash 拼凑 |
| 60 | 语气风格 | 简洁直接、中文回答 |
| 70 | 文本输出 | Markdown 代码块、结构化输出 |
| 80 | 自定义指令 | MEWCODE.md 三层合并内容(可选空槽) |
| 90 | 可用 Skill 列表 | Skill catalog 摘要(可选空槽) |
| 100 | 长期记忆 | MEMORY.md 索引(可选空槽) |
// prompt/prompt.go:11 —— 装配算法
func AssembleSystem(mods []Module) string {
sorted := make([]Module, len(mods))
copy(sorted, mods) // 防御性拷贝,避免副作用
sort.SliceStable(sorted, func(i, j int) bool { // ← SliceStable,保证同 priority 顺序稳定
return sorted[i].Priority < sorted[j].Priority
})
var parts []string
for _, m := range sorted {
if m.Content != "" { // 空槽跳过,不留多余空行
parts = append(parts, m.Content)
}
}
return strings.Join(parts, "\n\n")
}注释里的关键一句:排序稳定以保证跨调用逐字节一致(N1 缓存确定性)。
这是为 Prompt Cache 服务的:如果模块顺序在不同调用间变化(比如用 sort.Slice 的快速排序,同 priority 元素顺序不确定),生成的系统提示就不是逐字节一致的,缓存全部失效。用 SliceStable + 固定 priority 值 = 逐字节稳定。
5.3 缓存通道分离
这是本项目在 Prompt 工程上最有价值的设计。
// prompt/environment.go:14 —— 环境信息是每轮变化的
type Environment struct {
WorkingDir string // os.Getwd()
Platform string // runtime.GOOS
Date string // time.Now().Format("2006-01-02")
GitStatus string // git status --porcelain 摘要
Version string
Model string
}这些字段(尤其是 Date 和 GitStatus)每轮都可能变。如果把它们拼在系统提示里,缓存前缀就被破坏了。
所以 System 被拆成两段:
┌─────────────────────────────────────────┐
│ System.Stable(稳定系统提示) │ ← Anthropic: cache_control 断点打在这里
│ = 7 固定模块 + 自定义指令 + Skill + 记忆 │ ← OpenAI: 靠前缀自动命中
├─────────────────────────────────────────┤
│ System.Environment(环境信息) │ ← 不打缓存断点,排在稳定段之后
│ = 工作目录 / 平台 / 日期 / Git 状态 │ 变化不影响前面的缓存
└─────────────────────────────────────────┘Anthropic 侧:
// anthropic.go:120
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
}一个真实的 SDK 陷阱(面试可以讲):注释里写「必须用
NewCacheControlEphemeralParam构造器,空字面量会被omitzero丢弃」。因为 SDK 结构体字段带omitzero序列化标签,直接写CacheControl: anthropic.CacheControlEphemeralParam{}会在 JSON 里消失,服务端看不到cache_control,缓存静默失效。这是 Go 零值语义与「字段存在性有语义」的协议设计冲突,只有读 SDK 源码才能发现。
OpenAI 侧:
// openai.go:36 —— 拼成一条 system 消息
// Stable + "\n\n" + Environment
// 注释:单条拼接兼容端点对多条 system 支持不一;Stable 居前缀使端点前缀缓存自动命中两家用不同的机制实现同一件事:Anthropic 靠显式断点,OpenAI 靠前缀匹配。而 System{Stable, Environment} 的字段划分恰好同时服务两种实现——这是好的抽象的标志。
5.4 Reminder 注入机制
问题:运行中需要给模型注入临时指令(「你在计划模式」「记得用中文」),但:
- 不能塞进 System Prompt(会破坏缓存)
- 不能当成 user 消息(模型会以为用户在提问并回复它)
方案:单独的 Reminder 字段 + <system-reminder> 标签。
// prompt/reminder.go:15
func SystemReminder(body string) string {
return "<system-reminder>\n" + body + "\n</system-reminder>"
}两家的注入位置不同(因为角色交替规则不同):
// anthropic.go:141 —— 末条是 user → 追加为该 user 消息的新文本块;末条非 user → 新起一条 user
// openai.go:91 —— 直接 append 一条尾部 user 消息(OpenAI 容忍连续 user)按轮次控制注入频率(Plan Mode):
// agent.go:1162
func (a *Agent) buildReminder(mode permission.Mode, iter int) string {
var parts []string
if mode == permission.ModePlan {
full := iter == 1 || (iter-1)%planReminderInterval == 0 // planReminderInterval = 4
if r := prompt.PlanReminder(full); r != "" {
parts = append(parts, r)
}
}
if a.runtime != nil {
for _, r := range a.runtime.TakeReminders() { // ← 取出即清空
parts = append(parts, r)
}
}
return strings.Join(parts, "\n\n")
}首轮发完整提醒,之后每 4 轮发一次完整版,其余轮次发精简版:
| 轮次 | 注入内容 |
|---|---|
| 1 | 完整:你当前处于计划模式。你只能使用只读工具…请产出分步计划,等待 /do |
| 2-4 | 精简:仍在计划模式。继续只读调研,产出计划后等待 /do。 |
| 5 | 完整((5-1)%4 == 0) |
| 6-8 | 精简 |
| 9 | 完整 |
为什么要这样做:完整提醒约 80 token,精简版约 25 token。如果每轮都发完整版,20 轮下来多花 1600 token。但完全不发,模型可能在长循环里「忘记」约束。按轮次衰减注入(full / concise / none 三态)是 token 成本与指令遵循度的折中。
TakeReminders() 的「取出即清空」语义(Hook 注入的提醒):
// runtime.go:192
func (r *SessionRuntime) TakeReminders() []string {
r.mu.Lock()
defer r.mu.Unlock()
reminders := r.PendingReminders
r.PendingReminders = nil // ← 关键是清空
return reminders
}保证 Hook 注入的提醒只生效一轮,不会在后续每一轮重复出现。
六、边界与已知缺陷(诚实清单)
| 缺陷 | 位置 | 影响 | 修法 |
|---|---|---|---|
MaxTokens 硬编码 4096 | anthropic.go:172 | 长代码生成会被截断,且截断时 StopReason == max_tokens 不被区分,上层只看到「回答突然结束」 | 配置化 + 处理 max_tokens 停止原因 |
| thinking 预算 16000 > MaxTokens 4096 | anthropic.go:172/184 | Anthropic 要求 budget_tokens < max_tokens,启用 thinking 的首轮可能 400;源码无交叉校验 | 加一致性检查或调高 MaxTokens |
| thinking 一旦用过工具就永久关闭 | hasToolHistory | Anthropic 带 tool_use 的多轮要求回传 thinking block,而 Message 结构不存 thinking,只能一刀切规避 | 给 Message 加 thinking 字段并原样回灌 |
| llm 层零重试、零超时 | 整个 llm 包 | 模型端静默挂起 → Agent 主循环无限等待(无 idle/首字节超时);429/5xx 不重试 | 加 timeout/max_retries 配置 + 指数退避 |
不检查 finish_reason | 两个适配器 | length(截断)/ content_filter / refusal 全被当正常结束 | 补全 stop reason 语义 |
APIKey 只从 YAML 明文读 | config.go | 密钥落盘;且不支持 os.Getenv 展开(MCP 配置支持) | 支持 ${VAR} 展开 |
api_key 为空直接拒绝启动 | config.go:77 | 无法对接无鉴权的本地端点(Ollama/vLLM 需填任意值) | 允许 base_url + 空 key 组合 |
| 未知 Role 被静默丢弃 | 两个 switch m.Role 无 default | 将来加 RoleSystem 会悄无声息失效 | 加 default 分支打日志 |
RoleTool 无 ToolResults | anthropic.go:80 | Anthropic 侧生成零内容块的 user 消息 → 可能 400;OpenAI 侧什么都不发;两家行为不一致 | 加长度检查 |
只取 Choices[0] | openai.go | 忽略 n > 1(本项目不设 n,暂时无影响) | 明确断言或遍历 |
| 工具定义不打缓存断点 | anthropic.go | 只用了 4 个断点中的 1 个(system),工具定义每轮重新计费 | 给工具列表加断点 |
七、面试官可能追问(Q&A)
L1 基础
Q1:为什么要做 Provider 抽象?
A:核心诉求是让
agent包零 SDK 依赖。消费者有三处——ReAct 循环、压缩器(摘要请求)、TUI(token 用量展示),共用一套事件语义。两家的差异(tool_result必须在 user 里、role:"tool"一条一结果、reminder 注入位置不同)全部收敛在两个转换函数里。代价是 SDK 的高级能力(thinking block 回传、多模态)被抹平,想用就得扩Message结构。
Q2:StreamEvent 为什么不用 Type 枚举 + Payload any?
A:用零值可判别的字段让消费端一个
switch就够,不需要 type assertion,也没有装箱分配。agent.go里的streamOnce就是按这个顺序匹配的。风险是新增事件类型时必须新增字段并更新注释里的不变量说明。
Q3:怎么支持「任意 OpenAI-compatible 端点」?
A:
ProviderConfig.BaseURL非空时透传给 SDK。配置里protocol: openai+base_url: http://localhost:11434/v1就能接 Ollama;接 DeepSeek、通义千问同理。注意api_key不能为空(配置校验会拒绝),本地无鉴权端点要填一个占位值。
L2 深挖
Q4:Prompt Cache 怎么做才能命中?
A:三层措施:① 分离稳定与变化内容——
System.Stable承载稳定前缀(7 个固定模块 + 项目指令 + 记忆索引),Environment排在后面且不打断点;② 保证逐字节稳定——AssembleSystem用sort.SliceStable按 priority 排序,模块顺序跨调用固定;③ 冻结压缩决策——ContentReplacementState保证同一个tool_use_id每轮回放的字符串是同一个 string 对象。当前只用了一个断点(system),工具定义和历史消息都没打断点,这是可以优化的点。
Q5:Anthropic 的缓存断点最多几个?为什么不用满?
A:最多 4 个。理论上最省钱的布局是「system 末尾 + tools 末尾 + 历史早期 + 历史近期」四个断点,这样每一段都能命中。本项目只用了 1 个(system 的 Stable 块),原因是:① 实现简单;② 本项目每轮都会重写系统提示(因为 Skill/记忆模块可能变化),保守起见只给最稳的那段打断点。改进方向是给工具列表也打断点,并在会话中增量推进历史断点位置。
Q6:Reminder 为什么不直接拼到 Messages 里?
A:因为两家协议的角色交替规则不同。Anthropic 要求 user/assistant 严格交替且
tool_result必须在 user 消息里,所以 reminder 要作为「追加的文本块」塞进末条 user 消息(末条不是 user 才新起一条);OpenAI 容忍连续 user,直接在末尾 append。如果统一在 Agent 层拼 Messages,就得在 Agent 层写协议分支——那协议抽象就白做了。
Q7:系统提示里的模块顺序为什么用 SliceStable 而不是 Slice?
A:
sort.Slice用的是不稳定的快速排序,同 priority 的元素顺序在不同调用间可能不同。而 Prompt Cache 要求逐字节一致——顺序一变,缓存全失效。用SliceStable+ 给所有模块固定的、互不相同的 priority 值,保证生成结果确定性。
Q8:为什么自定义指令(80)/ Skill(90)/ 记忆(100)排在固定模块之后?
A:按「稳定性」排序。固定模块(10-70)是编译期常量,永不变;自定义指令和记忆是启动时读一次,会话内不变;Skill 列表可能在会话中变化。越不稳定的越靠后,这样前面稳定的部分能持续命中缓存。
Q9:环境信息里的 GitStatus 是每轮都跑 git status 吗?
A:不是每轮——
GatherEnvironment在Run起始时调一次(agent.go:181),env.Render()每轮调用但只是字符串渲染。git status --porcelain带 2 秒超时,失败或非 git 目录则留空,不中断。
L3 故障与边界
Q10:如果 UI 停止读取事件 channel 会怎样?
A:
Stream用的是无缓冲 channel,生产 goroutine 会阻塞在 send 上——形成天然背压,内存不会堆积。真正的风险是泄漏:若消费端提前 return 且不取消 ctx,生产 goroutine 永久卡住。防御手段是每次 send 都写成:goselect { case ch <- ev: case <-ctx.Done(): return }所以真正保证正确性的是「ctx 一定会被取消」这个上层约定。
Q11:streamOnce 遇到错误就 return,已经打出来的文本为什么不算数?
A:
agent.go:494直接返回错误,调用方ensureAssistantTail(conv, noticeStreamErr)补的是错误提示而不是部分文本。好处:不会把「半截回答」当成完整输出喂回下一轮。坏处:UI 上已流式渲染的文本与持久化历史不一致(重新加载会话时会消失)。要一致的话应该在兜底时带上textBuilder.String()。
Q12:如果模型返回的工具参数是非法 JSON 会怎样?
A:本层不校验。Anthropic 侧
json.RawMessage原样塞进 SDK,序列化/服务端校验阶段才可能报错;OpenAI 侧作为字符串传递。工具执行时如果json.Unmarshal失败,工具自己会返回结构化错误(比如Agent工具返回「参数解析失败」),回灌给模型让它重试。
Q13:如何在没有精确 tokenizer 的情况下估算发送成本?
A:本项目不在
llm层做估算——估算完全交给compact包(锚点 + 字符/3.5 增量)。llm层只在响应里提供真实的 usage,让compact用来校准锚点。这是职责切分:协议层负责「如实上报」,上下文层负责「估算与决策」。
L4 设计与权衡
Q14:New() 用 switch 还是注册表?
A:用
switch显式分支。协议只有两种、编译期可穷尽,注册表(map[string]func+init())在这个规模下只是额外间接层。default分支返回带中文说明的错误,已经提供了可诊断性。如果要做成插件式(第三方扩展协议),再改成Register(protocol, factory)。
Q15:如果让你重新设计这一层,你会改什么?
A:四点:① 补全 stop reason 语义——
length/content_filter/refusal直接影响「要不要重试」和「要不要告警」,现在全被当正常结束;② 支持 thinking block 回传——扩Message结构,让长会话也能用扩展思考;③ 加超时与重试——至少要有 idle timeout(模型不吐 token 超过 N 秒就断开),并区分「可重试错误」与「不可重试错误」;④ 加请求级可观测——request id、耗时、TTFT(首 token 时间)采集,这是生产排障的必需品。
Q16:为什么 Provider.Stream 不返回 (<-chan StreamEvent, error)?
A:因为流式请求的错误可能发生在「连接建立后、事件中途」,与返回值时机不匹配。如果拆成两个返回值,消费端要
select两个 channel 并依赖「谁先关闭」的约定。统一走事件流是最少约定的方案。代价是「构造请求失败」这类同步错误也只能在 goroutine 里通过事件发出。
八、企业级方案对照
8.1 三条路线
| 路线 | 做法 | 代表 | 取舍 |
|---|---|---|---|
| ① SDK 直连 + 自写适配 | 每家的官方 SDK 各包一层 | 本项目、多数自研 Agent | 灵活、可控;但要自己维护 N 份适配 |
| ② 统一网关 | 客户端只对接一种协议,网关负责转换 | LiteLLM、vLLM 前置、Envoy AI Gateway、One-API | 客户端极简、能统一鉴权限流计费;但网关是瓶颈与单点 |
| ③ 语义化事件协议 | 定义抽象事件类型,客户端按类型驱动状态机 | OpenAI Responses API(response.output_text.delta 等) | 语义清晰、可扩展;但需要生态支持 |
企业的典型组合:内部用 ②(网关统一供应商、做配额与审计),客户端用 ①(保留协议能力)。
8.2 企业级必须补齐的七件事
| # | 能力 | 本项目状态 | 生产做法 |
|---|---|---|---|
| 1 | 重试与退避 | ❌ 零重试 | 按错误类型分级:429/503 指数退避 + jitter;400/401 不重试;流式中途失败要不要重试是关键设计点(已产生 Text 的重试会重复输出) |
| 2 | 超时控制 | ❌ 无 idle 超时 | per-request deadline + 首字节超时 + idle 超时(流式场景必须有 idle,否则模型挂起会永久等待) |
| 3 | 模型路由与降级 | ❌ 单模型 | 按任务复杂度路由(简单任务走小模型);主模型失败 fallback 到备用模型;本地模型兜底 |
| 4 | 成本归因 | ⚠️ 只展示 token | 按 session / user / tenant / feature 维度聚合 token 与金额;实时预算告警 |
| 5 | 可观测 | ❌ 无 | OTel Trace(每次 LLM 调用一个 span,带 model、token、TTFT、cache hit)+ Prometheus 指标 |
| 6 | 缓存治理 | ⚠️ 1 个断点 | 断点规划(system/tools/history 分层)+ cache hit rate 监控告警(掉到阈值以下说明前缀被破坏了) |
| 7 | 协议能力对齐 | ⚠️ 抹平了高级能力 | 保留多模态、structured output、thinking、citations 等能力的透传通道 |
8.3 重点讲一个:Prompt Cache 的经济账
为什么企业级必须认真做缓存:
假设:System Prompt 8K token + 工具定义 2K token + 历史 20K = 30K input tokens/轮
一个 20 轮的编码任务 ≈ 600K input tokens(不考虑历史增长)
无缓存:600K × $3/M = $1.8
有缓存:600K × $0.3/M = $0.18 ← 便宜 10 倍但缓存有苛刻的前置条件:
| 条件 | 破坏它会发生什么 |
|---|---|
| 前缀逐字节一致 | 系统提示里插了时间戳 → 全量失效 |
| 断点位置合理 | 断点放在变化内容之后 → 断点前的部分命中不了 |
| 未过期 | Anthropic 默认 5 分钟 TTL,长时间空闲后缓存过期 |
| 同一模型/端点 | 切换模型 → 缓存不共享 |
企业级监控指标:
cache_hit_rate = cache_read_tokens / (cache_read_tokens + input_tokens)
# 健康值 > 70%;掉到 30% 以下说明前缀被破坏了,需要排查本项目已经具备的:Usage.CacheRead / CacheWrite 已从 provider 采集并转发到 UI(agent.go:337)。缺的是:聚合指标与告警。这是一个很好的「我知道下一步该做什么」的答案。
九、本章速记卡
协议抽象 Provider 接口 3 方法:Name / Model / Stream
错误走 channel(StreamEvent.Err),接口无 error 返回值
StreamEvent 五态:Text / ToolCalls / Usage / Done / Err
Done 与 Err 互斥且终结;Text 与 ToolCalls 不同时非空
数据模型 Message{Role, Content, ToolCalls, ToolResults}
Role 三种:user / assistant / tool(自造中间层语义)
ToolCall.Input 是 json.RawMessage(避免 key 顺序变化破坏缓存)
System{Stable, Environment} —— 缓存策略的载体
Request{Messages, Tools, System, Reminder}
协议差异 tool_result 位置(Anthropic 在 user 里 / OpenAI 独立消息)
system 结构(多 block / 单消息)
工具 schema(只吃 properties+required / 整块透传)
reminder 注入(追加文本块 / 新增 user 消息)
缓存控制(显式断点 / 自动前缀)
参数拼接 Anthropic: acc.Accumulate(event)
OpenAI: acc.AddChunk(evt)
都只在流结束后一次性发 ToolCalls(片段不可解析)
PTL 识别 错误文本子串匹配 → ErrPromptTooLong 哨兵
恢复链:紧急压缩 → ResetAnchor → 重试一次 → 仍失败则放弃
提示工程 7 固定模块(10-70) + 3 可选槽(80 自定义指令 / 90 Skill / 100 记忆)
AssembleSystem 用 sort.SliceStable 保证逐字节稳定(缓存)
环境段单独渲染,不打缓存断点,排在稳定段之后
Anthropic: cache_control 打在 Stable 块(必须用构造器,否则被 omitzero 丢弃)
OpenAI: Stable + "\n\n" + Environment 拼一条(兼容端点)
Reminder <system-reminder> 标签包裹
Plan Mode: 首轮完整,之后每 4 轮完整,其余精简
TakeReminders() 取出即清空(只生效一轮)
已知缺陷 MaxTokens 硬编码 4096 / thinking 预算 16000 无交叉校验 /
零重试零超时 / 不检查 finish_reason / api_key 明文无环境变量 /
thinking 一旦用过工具就永久关闭 / 仅 1 个缓存断点