Skip to content

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:

go
// ❌ 不做抽象: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 抽象方案 ​

go
// llm/provider.go:91 —— 只有三个方法
type Provider interface {
    Name() string                                               // 状态栏左侧
    Model() string                                              // 状态栏右侧
    Stream(ctx context.Context, req Request) <-chan StreamEvent // 发起一轮流式对话
}

关键设计:错误也走 channel,接口没有 error 返回值。

go
type StreamEvent struct {
    Text      string     // 文本增量
    ToolCalls []ToolCall // 非空:本轮模型请求执行这些工具
    Usage     *Usage     // 非空:本轮 token 用量(Done 之前一次性发出)
    Done      bool       // 本轮正常结束
    Err       error      // 出错
}

文档注释里显式定义了五态语义的不变量:

Text 与 ToolCalls 可先后出现但不同时非空;Done/Err 互斥且为终结事件。

为什么这么设计:一次流式请求的错误可能发生在连接建立之后、事件流中途(网络抖动、模型服务异常),这与「函数返回值」的时机不匹配。统一走 channel 后,消费端只需要一个 for range + 一个 switch:

go
// 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 核心类型 ​

go
// 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(已含标签;空=不注入)
}

四个刻意的设计:

  1. RoleTool 是自己造的概念——不叫 tool_result(Anthropic 的叫法)也不叫 function(OpenAI 旧叫法),纯中间层语义。
  2. ToolCall.Input 是 json.RawMessage 而不是 string——Anthropic 侧直接塞进 SDK,避免「解析成 map 再序列化」造成的 key 顺序变化(会破坏缓存);OpenAI 侧转成 string。
  3. System 拆两段是缓存策略,不是审美(详见 §5.3)。
  4. Reminder 单独成字段——因为两家协议的角色交替规则不同,注入位置必须由适配器决定(详见 §5.4)。

3.2 两家协议的差异对照 ​

维度AnthropicOpenAI本项目的统一表达
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_tokensprompt_tokens / prompt_tokens_details.cached_tokens / 无 cache writeUsage{Input, Output, CacheWrite, CacheRead}
PTL 错误消息"prompt is too long""context_length_exceeded"llm.ErrPromptTooLong 哨兵

这个表就是「协议抽象到底抽象了什么」的完整答案。面试时能把这张表讲清楚,说明你真的对接过两家 API。

3.3 工具调用参数是怎么拼的 ​

核心事实:拼接不在本项目代码里逐片段做,而是靠 SDK 的 Accumulator。

go
// 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})     // ← 一次性发出,不逐片段
}
go
// 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 靠错误文本子串识别(无奈但务实) ​

go
// 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 模块化装配 ​

go
// 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 索引(可选空槽)
go
// 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 工程上最有价值的设计。

go
// 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 侧:

go
// 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 侧:

go
// openai.go:36 —— 拼成一条 system 消息
// Stable + "\n\n" + Environment
// 注释:单条拼接兼容端点对多条 system 支持不一;Stable 居前缀使端点前缀缓存自动命中

两家用不同的机制实现同一件事:Anthropic 靠显式断点,OpenAI 靠前缀匹配。而 System{Stable, Environment} 的字段划分恰好同时服务两种实现——这是好的抽象的标志。

5.4 Reminder 注入机制 ​

问题:运行中需要给模型注入临时指令(「你在计划模式」「记得用中文」),但:

  • 不能塞进 System Prompt(会破坏缓存)
  • 不能当成 user 消息(模型会以为用户在提问并回复它)

方案:单独的 Reminder 字段 + <system-reminder> 标签。

go
// prompt/reminder.go:15
func SystemReminder(body string) string {
    return "<system-reminder>\n" + body + "\n</system-reminder>"
}

两家的注入位置不同(因为角色交替规则不同):

go
// anthropic.go:141 —— 末条是 user → 追加为该 user 消息的新文本块;末条非 user → 新起一条 user
// openai.go:91      —— 直接 append 一条尾部 user 消息(OpenAI 容忍连续 user)

按轮次控制注入频率(Plan Mode):

go
// 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 注入的提醒):

go
// 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 硬编码 4096anthropic.go:172长代码生成会被截断,且截断时 StopReason == max_tokens 不被区分,上层只看到「回答突然结束」配置化 + 处理 max_tokens 停止原因
thinking 预算 16000 > MaxTokens 4096anthropic.go:172/184Anthropic 要求 budget_tokens < max_tokens,启用 thinking 的首轮可能 400;源码无交叉校验加一致性检查或调高 MaxTokens
thinking 一旦用过工具就永久关闭hasToolHistoryAnthropic 带 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 无 ToolResultsanthropic.go:80Anthropic 侧生成零内容块的 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 都写成:

go
select { 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 个缓存断点

持续学习,持续构建。