Skip to content

第 7 阶段每日计划:Go 与高性能 Agent 服务 ​

周期: 4 周(Phase 7 / Week 1-4,独立编号,非全局周号) 每日投入: 工作日约 1-1.5 小时 / 周末 2-3 小时 总产出物: Agent 网关(多厂商路由 + SSE 流式 + 限流)上线,可压测、有 README

学习方法(本阶段专属) ​

  1. 双锚点对比 — 你已会 Python 和 TS,每学一个 Go 概念先问"Python 里对应什么?TS 里呢?为什么设计不同"。差异处就是理解增长处。
  2. 并发是语言不是库 — 本阶段认知核心。不要用"背 API"的方式学 goroutine/channel,用"和 asyncio 对比"的方式学。
  3. 项目驱动 — Week 3 起所有代码都为网关服务。每学一个新特性,先问"网关里哪一块用得上它"。
  4. race 检查是日常 — 从 Week 2 起,go test -race ./... 不干净就不算写完。

进度追踪 ​

  • [ ] Week 1 - Go 语法速通(对比 Python/TS 学)
  • [ ] Week 2 - 并发模型 + HTTP 服务 + 工程化
  • [ ] Week 3 - 网关项目核心(转发 + 流式 + 路由 + 限流)
  • [ ] Week 4 - 缓存 + 可观测 + 部署 + 压测 + 作品集化
  • [ ] 阶段产出物:Agent 网关上线 + 压测报告 + README

环境准备(Day 0,提前一晚) ​

bash
# 1. 安装 Go(macOS,官网 https://go.dev/dl/ 或 Homebrew)
brew install go

# 2. 验证
go version

# 3. 建第一个项目:Go 不需要虚拟环境,一个目录就是一个模块
mkdir hello-go && cd hello-go
go mod init hello-go

# 4. 创建 main.go
cat > main.go << 'GOEOF'
package main

import "fmt"

func main() {
	fmt.Println("hello, go")
}
GOEOF

# 5. 运行 / 编译
go run .        # 直接运行
go build        # 产出单个静态二进制 hello-go,./hello-go 可执行

# 6. VS Code 安装扩展:Go(Go Team at Google),打开 .go 文件按提示装好 gopls 等工具

完成标志: go run . 输出 hello, go;go build 产出的二进制能直接执行;VS Code 保存时自动格式化(gofmt)。

和 Python 的第一处不同: 没有 uv、没有虚拟环境、没有解释器版本管理——go mod init 之后,这门语言的依赖管理就齐了。部署产物是一个静态二进制,这就是 Go 服务 Docker 镜像能做到几 MB 的原因(Week 4 会用到)。


Day 0.5:5 分钟——用 Go 跑通你的第二次 LLM 调用 ​

为什么加在这里: Phase 1 你用 Python 跑通过一次。现在用 Go 再跑一次同样的调用——代码不用理解,跑通就行,把"用新语言干活"的兴奋感锚定住。Week 1 结束时你会完全理解这 40 行。

操作步骤 ​

bash
# 1. 设置 API Key(同 Phase 1,环境变量,绝不进代码)
export OPENAI_API_KEY=sk-your-key-here

# 2. 在 hello-go 目录安装官方 SDK
go get github.com/openai/openai-go

# 3. 创建 first-llm.go
cat > first-llm.go << 'GOEOF'
package main

import (
	"context"
	"fmt"
	"os"

	"github.com/openai/openai-go"
	"github.com/openai/openai-go/option"
)

func main() {
	client := openai.NewClient(
		option.WithAPIKey(os.Getenv("OPENAI_API_KEY")),
	)
	resp, err := client.Chat.Completions.New(context.Background(), openai.ChatCompletionNewParams{
		Model: "gpt-5-latest",
		Messages: []openai.ChatCompletionMessageParamUnion{
			openai.UserMessage("你好,用一句话介绍你自己。"),
		},
		MaxTokens: openai.Int(100),
	})
	if err != nil {
		panic(err)
	}
	fmt.Println(resp.Choices[0].Message.Content)
}
GOEOF

# 4. 运行(缺包会提示,go mod tidy 自动补)
go mod tidy && go run first-llm.go

如果输出了一段 AI 的自我介绍——你已经用第二后端语言完成了 LLM 调用。

现在不用懂 context.Background() 和 if err != nil——前者是 Week 2 的主角,后者是 Day 4 的主角。留好这个文件。


Week 1:Go 语法速通(对比 Python/TS 学) ​

本周目标: 把 Python/TS 的编程经验迁移到 Go,掌握 80% 场景的 20% 语法 本周产出: Go 版 CLI 聊天机器人 v1(多轮对话 + 切换模型;流式为 P1 加分)

Day 1(周一):变量、类型与函数——"显式"的开端 ​

学习目标: 理解 Go 的静态类型与零值设计,习惯多返回值与大小写可见性。

核心概念 ​

  • 声明方式: var x int / x := 10(短声明,函数内用)。:= 是"声明并推导",最接近你 TS 里 const x = 10 的手感。
  • 零值思维: Go 没有"未定义变量"。int 零值 0,string 零值 "",切片/map/指针零值 nil。声明即可用,没有 TS 里 undefined 的整个问题域。
  • 多返回值: Go 没有 tuple,但函数天然多返回值——(result, error) 双返回是全语言的错误处理基石(Day 4 展开)。
  • 可见性: 标识符首字母大写 = 导出(public),小写 = 包内私有。没有 export/_ 前缀这些约定,规则只有一条。

代码示例 ​

go
package main

import "fmt"

// 多返回值:结果 + 错误,Go 错误处理的标准形态
func divide(a, b float64) (float64, error) {
	if b == 0 {
		return 0, fmt.Errorf("除数不能为 0")
	}
	return a / b, nil // nil 表示"没有错误"
}

func main() {
	// 短声明 + 类型推导
	x, y := 10, 3
	// _ 忽略不想要的返回值(对比 Python 的 _)
	quotient, err := divide(float64(x), float64(y))
	if err != nil {
		fmt.Println("出错了:", err)
		return
	}
	fmt.Println(quotient)
}

对比锚点: Python 里 10 / 3 自动是 float,Go 里 int/int 会编译报错——静态类型不允许你" assumed it works"。显式转换 float64(x) 会在你代码里大量出现,这是"显式一切"文化的第一课。

今日任务 ​

  • [ ] 跑通 A Tour of Go 的 Basics 部分(Variables、Functions、零值)
  • [ ] 写一个小函数 parsePrice(s string) (float64, error):字符串转价格,空串/非法输入返回明确 error
  • [ ] 给它写 3 个调用:正常值、空串、非法字符串,验证错误分支

自检清单 ​

  • [ ] 我能说出 3 个以上类型的零值(int/string/slice/指针)
  • [ ] 我能解释为什么 Go 选择"返回 error"而不是"抛异常"(先有自己的直觉,Day 4 系统讲)
  • [ ] 我知道 := 和 var 各用在哪

Day 2(周二):流程控制与集合——for 是唯一的循环 ​

学习目标: 掌握 for/if/range 与 slice/map,这是日常 Go 代码的主体。

核心概念 ​

  • for 是唯一循环关键字: 没有 while,没有 do-while。for cond {} 就是 while;for {} 就是死循环;for i := range n 是计数循环。
  • if 可以带初始化语句: if err := f(); err != nil {}——错误处理惯用式的语法基础,作用域被限制在 if 内。
  • 切片(slice)是主力: 数组长度固定几乎不用;slice 动态扩容,append 是日常。类比:Go 数组 ≈ TS 的定长 tuple,slice ≈ Python 的 list。
  • map: map[string]float64,类似 Python dict / TS 的 Record。零值 map 是 nil,读没问题、写会 panic——用 make 初始化。

代码示例 ​

go
// if 带初始化:err 的作用域被限制在 if/else 里,这是 Go 最高频的惯用式
if v, err := parsePrice("199.0"); err != nil {
	fmt.Println("解析失败:", err)
} else {
	fmt.Println("价格:", v)
}

// range 遍历 slice:i 是下标,s 是元素副本(类比 Python 的 enumerate)
models := []string{"gpt-5-latest", "claude-sonnet-4-5-latest"}
for i, m := range models {
	fmt.Println(i, m)
}

// map:统计词频这种事 Go 的写法
counts := make(map[string]int)
for _, w := range []string{"go", "llm", "go"} {
	counts[w]++
}

对比锚点: Python 的 enumerate/zip/推导式在 Go 里都不存在,全部用显式 for 循环表达。第一周会觉得啰嗦,两周后你会开始欣赏这种"一眼到底"的可读性。

今日任务 ​

  • [ ] Tour of Go 的 Flow control + More types 部分
  • [ ] 实现 wordFreq(text string) map[string]int:按空格切词统计词频(用 strings.Fields)
  • [ ] 用表驱动思路造 3 组输入跑它(含空串),打印结果

自检清单 ​

  • [ ] 我能不用查资料写出 for i, v := range slice 和 for k, v := range map
  • [ ] 我知道 nil map 读和写的行为差异
  • [ ] 我能解释 if err := f(); err != nil 里 err 的作用域

Day 3(周三):struct 与方法——没有 class,只有组合 ​

学习目标: 用 struct + 方法 + 嵌入替代你熟悉的 class/继承思维。

核心概念 ​

  • struct ≈ TS 的 interface + data 的合体: 定义数据形状;方法挂在类型上(func (s Session) Send(...)),接收者就是 this/self。
  • 值接收者 vs 指针接收者: 值接收者拿到副本(改不动原值);指针接收者 *Session 拿到原值引用(能改,且避免大结构拷贝)。经验法则:需要修改状态就上指针,其余统一指针省心。
  • 没有继承,只有嵌入: struct 可以嵌入另一个 struct 获得字段与方法的"提升",这是组合优于继承的语法化。Python 的 class B(A) 在 Go 里是 type B struct { A }。
  • 没有构造函数: 惯用 NewSession(...) *Session 工厂函数代替 __init__。

代码示例 ​

go
// 会话:对标你 Phase 1 chatbot 里的 Session 类
type Session struct {
	Model    string
	Messages []map[string]string // 简化示意,Day 5 会换成真正的消息结构
}

// 指针接收者:要修改 s.Messages,必须用 *Session
func (s *Session) AddMessage(role, content string) {
	s.Messages = append(s.Messages, map[string]string{"role": role, "content": content})
}

// 工厂函数代替构造函数
func NewSession(model string) *Session {
	return &Session{Model: model}
}

// 嵌入:Base 的字段和方法被 Conversation "继承"
type Base struct {
	ID string
}
type Conversation struct {
	Base      // 匿名嵌入,c.ID 直接可用
	Topic string
}

对比锚点: Pydantic 模型 class ChatSession(BaseModel) 的角色在 Go 里由 struct + Day 5 的表驱动校验(或第三方校验库)承担。继承树在 Go 里不存在——遇到"想继承"的冲动,先想"能不能组合"。

今日任务 ​

  • [ ] Tour of Go 的 Methods and interfaces 前半(Methods)
  • [ ] 把 Day 2 的 wordFreq 重构为一个 Analyzer struct:指针接收者方法 Add(text string) 和 TopN(n int) []string
  • [ ] 故意用值接收者写一版 Add,观察为什么统计结果丢失——用注释记下原因

自检清单 ​

  • [ ] 我能判断一个方法该用值接收者还是指针接收者并说明理由
  • [ ] 我能解释嵌入和继承的区别(没有多态重写!)
  • [ ] 我知道 Go 没有 class/继承/构造函数,分别用什么替代

Day 4(周四):interface 与 error——本周最关键的一天 ​

学习目标: 理解隐式接口与"error 是值"的设计哲学,这是 Go 错误处理和抽象的根基。

核心概念 ​

  • 接口是隐式实现的: 类型只要拥有接口要求的方法就自动满足接口,不需要 implements 声明。TS 的 structural typing 很像,但 TS 只检查形状、Go 要求方法真的存在。威力:你可以给别人包里的类型实现自己的接口。
  • error 就是一个内置接口: type error interface { Error() string }。任何类型实现了 Error() string 方法就是 error。错误不是异常流,是普通返回值——所以你必须处理它,编译器和 linter 会盯着你。
  • 错误包装与判断: fmt.Errorf("调用失败: %w", err) 用 %w 包装保留错误链;errors.Is(err, Target) 判断是不是某个哨兵错误;errors.As(err, &myType) 提取特定错误类型。类比 Python 的 raise ... from e + except SpecificError。
  • 什么时候用 interface: 需要多态/解耦时才定义(消费者侧定义,不是生产者侧——这点和 Java 相反)。滥用 interface{}(任意类型,今名 any)= 放弃类型系统,是反模式(见反模式库 #21)。

代码示例 ​

go
// 定义一个 Provider 接口:Week 3 网关的多厂商路由就是它的放大版
type Provider interface {
	Chat(ctx context.Context, model, prompt string) (string, error)
}

// OpenAIProvider 隐式实现了 Provider——注意没有任何 implements 声明
type OpenAIProvider struct{ APIKey string }

func (p *OpenAIProvider) Chat(ctx context.Context, model, prompt string) (string, error) {
	if p.APIKey == "" {
		// 哨兵错误:调用方可用 errors.Is 精确判断
		return "", fmt.Errorf("缺少 API Key: %w", ErrNoKey)
	}
	return "response", nil
}

// 调用侧:错误处理的"must handle"文化
resp, err := p.Chat(ctx, "gpt-5-latest", "你好")
if err != nil {
	return fmt.Errorf("openai 调用失败: %w", err) // 包装后向上传,保留错误链
}

对比锚点: Python 的 try/except 允许"先不处理"(异常会飞走),Go 的 (resp, err) 不允许(不处理编译器/lint 就报)。Java 的 checked exception 想做同样的事但败在啰嗦,Go 用多返回值做到了。这个设计是全语言风格分歧最大的一点,也是 Go 代码"看起来无聊"的根源——无聊即可靠。

今日任务 ​

  • [ ] Tour of Go 的 Methods and interfaces 后半(Interfaces + Errors)
  • [ ] 给 Day 3 的 Analyzer 加一个 ErrEmpty 哨兵错误:空文本输入返回它;调用侧用 errors.Is 区分"空输入"和"其他错误"
  • [ ] 定义 Renderer 接口(Render() string),给两个不同类型实现它,放进 []Renderer 一起调用——体会"隐式实现 + 组合"

自检清单 ​

  • [ ] 我能给一个已有类型(包括标准库类型)实现自己的接口
  • [ ] 我会正确使用 %w 包装错误并用 errors.Is/As 解包判断
  • [ ] 我能向面试官解释"error 是值"意味着什么、和异常的取舍是什么

Day 5(周五):go mod、testing 与周验收——Go 版 chatbot v1 ​

学习目标: 掌握依赖管理与表驱动测试,并把本周所学组装成 chatbot。

核心概念 ​

  • go mod: go mod init <module> 建模块;go get <pkg> 加依赖;go mod tidy 清理。版本记录在 go.mod/go.sum(对标 pyproject.toml + lock 文件,但内建,无第三方工具)。
  • testing 内建: 测试文件以 _test.go 结尾,测试函数 TestXxx(t *testing.T),go test ./... 跑。没有 pytest 那种第三方依赖——测试是一等公民。
  • 表驱动测试: Go 社区最惯用的测试模式——用例切片 + 循环断言,等价于 pytest 的 parametrize。
  • 不用 mock 框架起步: 给依赖定义小接口(Day 4 的能力),测试时塞假实现。Day 4 的 Provider 接口天然就是为这个准备的。

代码示例 ​

go
// price_test.go:表驱动测试,对标 pytest 的 @pytest.mark.parametrize
func TestParsePrice(t *testing.T) {
	tests := []struct {
		name    string
		input   string
		want    float64
		wantErr bool
	}{
		{"正常价格", "199.0", 199.0, false},
		{"空串", "", 0, true},
		{"非法输入", "abc", 0, true},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got, err := parsePrice(tt.input)
			if (err != nil) != tt.wantErr {
				t.Fatalf("err = %v, wantErr = %v", err, tt.wantErr)
			}
			if got != tt.want {
				t.Errorf("got = %v, want = %v", got, tt.want)
			}
		})
	}
}

今日任务(周验收) ​

  • [ ] go mod init llm-chat,用 Day 0.5 的 SDK 调用重构成 Provider 接口 + OpenAI 实现(Day 4 的设计落地)
  • [ ] 实现 chatbot 主循环:bufio.Scanner 读用户输入 → Session 维护多轮 messages(Day 3)→ 流式或一次性输出回复;支持 model: <name> 指令切换模型
  • [ ] 给 Session 的消息管理写表驱动测试,go test -race ./... 通过
  • [ ] (P1 加分) 加流式输出:用 SDK 的流式接口 + bufio.Scanner 逐行打印

自检清单 ​

  • [ ] 我能不看示例独立写出表驱动测试骨架
  • [ ] 我能解释 chatbot 里每一处 if err != nil 各自保护的是什么
  • [ ] 把 Phase 1 Python 版 chatbot 和今天这版并排打开——我能指出至少 3 处"同一问题两种语言答案"的差异

Week 2:并发模型 + HTTP 服务 + 工程化 ​

本周目标: 攻下 Go 的灵魂——goroutine/channel/context,然后补上 HTTP 服务与工程化 本周产出: 并发 fetcher 工具(并发数/超时可配,race 干净)——它是网关的雏形

Day 6(周一):goroutine 与数据竞争 ​

学习目标: 会起 goroutine、会等待它们、能识别数据竞争。

核心概念 ​

  • go f() 起并发: 任意函数前加 go 就在新的 goroutine 里跑。没有 async/await 的函数变色——所有函数都是并发安全的调用形态,这是与 asyncio 最大的差别(Python 里 def 与 async def 是两个世界)。
  • goroutine 不是线程: 起始栈仅 KB 级,一个进程轻松开几十万个。类比:比 Python 线程(MB 级 + GIL)轻两个数量级,比协程多了真并行能力。
  • 数据竞争无处不在: 两个 goroutine 同时写同一变量 = 未定义行为,编译器不报!go run -race / go test -race 是唯一靠谱的探针。Python 的 GIL 一直"保护"着你,Go 给你真并行也把责任交给你。
  • sync.WaitGroup: 等一组 goroutine 结束的标准工具(Add/Done/Wait),最接近 asyncio.gather 的角色。

代码示例 ​

go
// 并发调用 N 个模型(示意):WaitGroup 等待 + race 检查
func callAll(prompts []string) []string {
	results := make([]string, len(prompts)) // 预分配:各 goroutine 写不同下标,无竞争
	var wg sync.WaitGroup
	for i, p := range prompts {
		wg.Add(1)
		go func() {
			defer wg.Done()
			results[i] = callLLM(p) // 每个下标只有一个 goroutine 写 → 安全
		}()
	}
	wg.Wait()
	return results
}

对比锚点: asyncio.gather(*tasks) ≈ wg.Wait();asyncio.create_task(f()) ≈ go f()。但 asyncio 的 await 把控制权显式交还事件循环,Go 的调度由运行时自动抢占——你不需要 await,也就没有"忘 await"这类 bug,换来的是必须自己防数据竞争。

今日任务 ​

  • [ ] Tour of Go 的 Concurrency 前半(Goroutines + Channels 第一页)
  • [ ] 写一个故意有数据竞争的小程序(两个 goroutine 对同一计数器自增 1000 次),分别用 go run 和 go run -race 跑,记录两种结果——亲眼看见竞争
  • [ ] 用 sync.WaitGroup + 互斥锁(sync.Mutex)修复它,race 干净

自检清单 ​

  • [ ] 我能解释"goroutine 里闭包捕获循环变量"要注意什么(Go 1.22+ 已按次捕获,但要知道为什么)
  • [ ] 我知道 -race 检出的是什么级别的错误、为什么编译器查不出来
  • [ ] 我能说出 asyncio.gather 和 WaitGroup 的对应关系与能力差异

Day 7(周二):channel 与 select——在 goroutine 之间传数据 ​

学习目标: 理解 CSP 通信模型,掌握 channel 生命周期与 select 多路复用。

核心概念 ​

  • "不要通过共享内存来通信,要通过通信来共享内存": channel 是类型化的管道,ch <- v 发送、v := <-ch 接收。Day 6 用锁保护共享变量,channel 是更 Go 的替代思路。
  • 无缓冲 channel = 同步握手: 发送方阻塞到有接收者(类似 queue.get() 的强同步版)。有缓冲 channel = 异步队列(容量满才阻塞)。
  • 关闭与遍历: close(ch) 表示不再有数据;for v := range ch 会自动在关闭后退出。由发送方关闭,接收方不关。
  • select: 同时等多个 channel 操作,谁就绪走谁——asyncio.wait(..., FIRST_COMPLETED) 的语法化。配 time.After 做超时是最高频模式。

代码示例 ​

go
// 多个 worker 的结果汇聚:结果 channel + 超时保护
func gatherWithTimeout(urls []string, timeout time.Duration) []string {
	results := make(chan string, len(urls)) // 有缓冲:发送不阻塞,无需泄漏保护
	for _, u := range urls {
		go func(u string) {
			results <- fetch(u)
		}(u)
	}
	var out []string
	for range urls {
		select {
		case r := <-results:
			out = append(out, r)
		case <-time.After(timeout):
			return out // 超时:拿到多少算多少
		}
	}
	return out
}

今日任务 ​

  • [ ] Tour of Go 的 Concurrency 后半(Channels、Buffered Channels、Select、Timeout)
  • [ ] Go by Example 过一遍 Worker Pools、Rate Limiting、Timeouts 三篇(Week 3-4 全会用到)
  • [ ] 实现"生产者-消费者":1 个生产者 goroutine 生成 10 条任务 → channel → 3 个消费者并发处理并打印,close 后自动退出

自检清单 ​

  • [ ] 我能解释无缓冲与有缓冲 channel 各自的阻塞条件
  • [ ] 我知道谁该 close channel、双 close/向已关 channel 发送会怎样
  • [ ] 我能写出 select + time.After 的超时等待模式

Day 8(周三):worker pool 与 context——并发模式的合体 + 超时取消 ​

学习目标: 组合出生产级并发模式;掌握 context 的超时与取消传播。

核心概念 ​

  • worker pool: 固定数量 goroutine 从任务 channel 取活干——限流并发的标准姿势(Week 3 网关限制"同时在途的 LLM 调用数"就是它)。对照:Python 里你要 asyncio.Semaphore 包一层,Go 里 worker pool 是惯用法。
  • context 是并发控制的总线: context.Background() 起根,WithTimeout/WithCancel 派生,取消沿调用树自动传播。Go 里所有会阻塞的函数第一个参数几乎都是 ctx context.Context——它是显式的、强制的 asyncio.Task 取消机制。
  • 取消要接力: context 只负责"通知"(ctx.Done() channel 关闭),耗时操作要自己监听并退出。网络请求用 http.NewRequestWithContext,取消会一路传到 socket 层。
  • 本周的系统性对比(写进你的笔记): asyncio 的取消是" cooperative 但靠约定",Go 的取消是"cooperative 且靠类型签名强制"(不带 ctx 的函数无法被取消)。

代码示例 ​

go
// worker pool:jobs channel 进、results channel 出,ctx 控制整体超时
func RunPool(ctx context.Context, tasks []string, workers int) []string {
	jobs := make(chan string)
	results := make(chan string, len(tasks))
	var wg sync.WaitGroup

	for i := 0; i < workers; i++ {
		wg.Add(1)
		go func() { // worker:从 jobs 取任务直到 channel 关闭
			defer wg.Done()
			for t := range jobs {
				results <- process(ctx, t)
			}
		}()
	}
	for _, t := range tasks {
		jobs <- t
	}
	close(jobs)
	wg.Wait()
	close(results)
	// ... 收集 results(示意)
	return nil
}

今日任务 ​

  • [ ] Go by Example 过 Context 与 Context Timeout 两篇
  • [ ] 给 Day 7 的生产者-消费者升级为 worker pool(worker 数可配),跑 20 个任务、3 个 worker,验证并发度
  • [ ] 给 process 加 time.Sleep(2s) 模拟慢任务,用 context.WithTimeout 让总时长超限时 worker 里的 select 走 ctx.Done() 分支提前退出

自检清单 ​

  • [ ] 我能画出"ctx 从 HTTP 请求进入 → http.Client → 下游调用"的取消传播链
  • [ ] 我能解释为什么 context 必须作为函数第一个参数显式传递
  • [ ] 我能说清 worker pool 和 asyncio.Semaphore 方案的差异与各自适合的场景

Day 9(周四):net/http——标准库直接上生产 ​

学习目标: 用纯标准库写出 JSON API 服务,理解 Handler 与中间件模型。

核心概念 ​

  • 一个函数就是一个 handler: func(w http.ResponseWriter, r *http.Request)。w 写响应,r 读请求。没有框架层的抽象税——FastAPI 的装饰器路由 vs Go 的显式注册,前者甜后者直白。
  • http.ServeMux 路由: Go 1.22+ 的 mux 支持 POST /api/chat 方法和路径参数写法,小服务不再需要第三方路由库。网关用标准库 mux 就够。
  • encoding/json: json.NewDecoder(r.Body).Decode(&req) / json.NewEncoder(w).Encode(resp),struct tag(json:"model")对标 Pydantic 的字段序列化——但没有运行时校验,入参校验要自己写(或用校验库)。
  • 中间件 = 函数套函数: func(h http.Handler) http.Handler,没有装饰器语法,但思想与 FastAPI middleware 完全一致。日志/恢复 panic/限流(Day 14)全走中间件。

代码示例 ​

go
// 一个最小 chat 端点:JSON 进出 + 中间件包装,就是网关端点的雏形
type chatReq struct {
	Model    string `json:"model"`
	Messages []struct {
		Role    string `json:"role"`
		Content string `json:"content"`
	} `json:"messages"`
}

func chatHandler(w http.ResponseWriter, r *http.Request) {
	var req chatReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, `{"error":"invalid json"}`, http.StatusBadRequest)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(map[string]string{"echo": req.Model})
}

// 中间件:包一层 handler,加计时日志
func withLogging(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		next.ServeHTTP(w, r)
		log.Printf("%s %s took %s", r.Method, r.URL.Path, time.Since(start))
	})
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("POST /api/chat", chatHandler)
	http.ListenAndServe(":8080", withLogging(mux))
}

今日任务 ​

  • [ ] 用标准库写一个 /api/echo POST 端点:JSON 进 JSON 出,非法 body 返回 400
  • [ ] 写两个中间件:logging(耗时)+ recover(捕获 handler panic 返回 500,recover() 是 Go 的 panic 兜底,服务里只允许中间件用它)
  • [ ] 用 httptest 包给端点写测试(不用起真端口),三个用例:正常/非法 JSON/错误方法

自检清单 ​

  • [ ] 我能不查资料写出 handler 签名和 mux 注册
  • [ ] 我能解释 Go 中间件与 FastAPI/Express 中间件的异同
  • [ ] 我知道 struct tag 是什么、丢 tag 会发生什么

Day 10(周五):工程化与周验收——并发 fetcher ​

学习目标: 建立 Go 工程化肌肉记忆,交出第一个 race 干净的并发工具。

核心概念 ​

  • golangci-lint: Go 世界的 Ruff——聚合了几十个 linter 的总闸。配置一次 .golangci.yml,CI 里 golangci-lint run。你 Phase 1 建立的 lint 习惯无缝迁移。
  • 标准项目布局(够用版): cmd/(入口)、internal/(私有代码,编译器强制外部不可导入——语言级的"私有"机制)、go.mod。小项目 main.go + internal/ 就够,别教条套目录。
  • 测试金字塔同构: 单元测试(表驱动)+ 集成测试(httptest)+ go test -race ./... 全绿,就是你 Phase 2 评测习惯的 Go 版。
  • gofmt 没有争论: 格式由工具决定,官方唯一方案。你从 Prettier 时代就懂的"格式别讨论"在 Go 是语言级共识。

今日任务(周验收) ​

  • [ ] 实现 并发 fetcher:输入 N 个 URL,参数可配并发数(worker pool)、单请求超时(context)、失败重试 1 次;输出每个 URL 的状态码与耗时
  • [ ] 用 httptest 起一个会超时的假服务器,给 fetcher 写集成测试:全部成功 / 部分超时 / 全部超时三档
  • [ ] 初始化 git 仓库 + .golangci.yml,golangci-lint run 与 go test -race ./... 双绿
  • [ ] (P1 加分) 给 fetcher 加 --limit QPS 参数,用 time.Ticker 或 x/time/rate 控制发起速率

自检清单 ​

  • [ ] fetcher 代码里每一条 goroutine 我都能说出它的退出路径(防泄漏意识,见反模式库 #20)
  • [ ] go test -race ./... 干净通过,且我知道它测了什么
  • [ ] 我能说出这个 fetcher 和 Week 3 网关的哪些模块是同构的(worker pool → 并发调度;context 超时 → 转发超时;重试 → 上游重试)

Week 3:网关项目核心 ​

本周目标: 把 Week 1-2 的全部零件组装成网关 MVP:openai 兼容端点 → 多厂商路由 → 流式透传 → 限流 → 超时重试 本周产出: 网关 MVP——Phase 6 的 chat 前端改个 base URL 就能接上

Day 11(周一):设计先行 + 项目骨架 ​

学习目标: 先设计后编码——列出需求、画出数据流、搭好项目骨架。

核心概念 ​

网关是什么: 站在应用和模型厂商之间的反向代理。应用只认 openai 协议(Phase 1-6 的全部代码都兼容它),网关按规则把请求转发给真正的厂商。业界对照物:LiteLLM(Python)、One API(Go)——先自己设计,再对照它们 diff 思路。

MVP 需求清单(本周 P0):

客户端(openai 协议)
   │  POST /v1/chat/completions
   ▼
┌─────────────── 网关 ───────────────┐
│ 1. 路由:model 名 → 目标厂商        │
│ 2. 改写:换 base URL / API Key      │
│ 3. 转发:含 SSE 流式(Day 13)      │
│ 4. 限流:令牌桶(Day 14)           │
│ 5. 护栏:超时 + 重试(Day 15)      │
└────────────────────────────────────┘
   ▼                ▼
 OpenAI        Anthropic(协议转换)

路由配置怎么存: 启动时从环境变量/配置文件加载一张映射表(gpt-5-latest → openai、claude-sonnet-4-5-latest → anthropic),热加载留给将来。配置结构体用 envconfig 或手写 os.Getenv + 默认值。

今日任务 ​

  • [ ] 把上面的需求清单抄进项目 README 的 Roadmap,明确本周 P0 / 后续 P1
  • [ ] go mod init llm-gateway,建骨架:cmd/gateway/main.go + internal/config/ + internal/proxy/ + internal/provider/
  • [ ] 实现 config 包:从环境变量读厂商 base URL 与 API Key(密钥只走环境变量!),缺失时启动报清晰错误
  • [ ] 跑通 go build ./...,骨架编译通过

自检清单 ​

  • [ ] 我能不看资料画出网关的请求数据流
  • [ ] 我知道为什么路由表要在启动时加载成结构体而不是每请求读 map 字面量
  • [ ] 我的密钥只出现在环境变量里(对照反模式库 #3——Go 项目同样适用)

Day 12(周二):转发与多模型路由 ​

学习目标: 实现非流式请求的完整转发链路:解析 → 路由 → 改写 → 转发 → 回写。

核心概念 ​

  • httputil.ReverseProxy vs 手写转发: 标准库 ReverseProxy 开箱即用但改写深度有限;网关要改 body(模型名映射、协议转换),手写更可控:读 body → 解析 model → 查路由表 → 换 URL 和鉴权头 → http.Client 发出 → 回写响应。
  • http.Client 全局复用: 全进程一个 http.Client(内建连接池)。每请求 &http.Client{} 是经典性能反模式——连接池失效、TIME_WAIT 堆积。
  • 协议转换最小集: 请求体里 messages/temperature 等字段 openai 与 Anthropic 略有差异;MVP 先支持"参数直传 + model 名映射",Anthropic 的差异字段转换做成一个纯函数,表驱动测试伺候。
  • 上游错误要透传语义: 厂商返回 429/5xx 时,网关应回传相近状态码与错误体,而不是吞成 500——调用方的重试逻辑依赖它。

代码示例 ​

go
// 转发核心(示意):读 body → 路由 → 改写 → 转发
func (g *Gateway) forward(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "read body failed", http.StatusBadRequest)
		return
	}
	var req chatRequest
	if err := json.Unmarshal(body, &req); err != nil {
		http.Error(w, "invalid json", http.StatusBadRequest)
		return
	}
	route, ok := g.routes[req.Model] // 路由表:model → {baseURL, apiKey, transform}
	if !ok {
		http.Error(w, fmt.Sprintf("unknown model: %s", req.Model), http.StatusNotFound)
		return
	}
	upstreamReq, _ := http.NewRequestWithContext(r.Context(), http.MethodPost,
		route.BaseURL+"/v1/chat/completions", bytes.NewReader(route.Transform(body)))
	upstreamReq.Header.Set("Authorization", "Bearer "+route.APIKey)
	upstreamReq.Header.Set("Content-Type", "application/json")

	resp, err := g.client.Do(upstreamReq) // g.client 全局复用
	if err != nil {
		http.Error(w, "upstream unreachable", http.StatusBadGateway)
		return
	}
	defer resp.Body.Close()
	w.WriteHeader(resp.StatusCode)
	io.Copy(w, resp.Body)
}

今日任务 ​

  • [ ] 实现 routes 路由表(map + 启动加载),支持至少两家厂商;未知名返回 404 + 明确错误体
  • [ ] 实现非流式转发全链路,用 curl 直接打厂商 API 和打网关对比响应一致
  • [ ] 给协议转换纯函数写表驱动测试(openai 请求体 → Anthropic 请求体的映射,覆盖 messages/system/参数缺省)
  • [ ] 跑 go test -race ./... 保持全绿

自检清单 ​

  • [ ] 我能解释为什么网关要自己持有路由表而不是让客户端传厂商 URL(安全 + 抽象)
  • [ ] 我知道 http.Client 该建几个、放在哪
  • [ ] 上游 429 时我的网关回给客户端的状态码是什么、为什么

Day 13(周三):SSE 流式透传——网关的灵魂 ​

学习目标: 实现 stream: true 请求的逐块透传,这是网关最难也最有价值的一块。

核心概念 ​

  • SSE 的本质: 一串 data: {...}\n\n 文本行,HTTP 是 Content-Type: text/event-stream 的长响应。Go 里没有"流 API",就是边读边写边 flush。
  • 三个关键件: bufio.Scanner 按行扫上游响应;http.Flusher 每收到一块就 Flush() 推给客户端(不 flush 会被缓冲住,客户端表现为"卡住直到结束");r.Context() 监听客户端断连,立刻取消上游请求。
  • 原样透传 vs 解析重打包: MVP 对 openai 兼容上游直接透传字节;对协议不同的上游(Anthropic 的事件格式不同)要解析→转换→重打包。先做透传,转换作为 Anthropic 分支的加分任务。
  • 测试流式的办法: httptest 里写一个 handler 分块写 SSE、每块 sleep 50ms——你的"假上游"就是 Week 4 压测和以后所有重构的安全网。

代码示例 ​

go
// SSE 透传核心:边扫边写边 flush,断连即取消上游
func streamProxy(w http.ResponseWriter, upstream io.ReadCloser, ctx context.Context) {
	flusher, ok := w.(http.Flusher)
	if !ok {
		http.Error(w, "streaming unsupported", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-cache")

	scanner := bufio.NewScanner(upstream)
	scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) // SSE 单行可能很大,调大缓冲
	for scanner.Scan() {
		select {
		case <-ctx.Done(): // 客户端断连:停读上游,连接会被关闭进而取消上游请求
			return
		default:
		}
		fmt.Fprintf(w, "%s\n", scanner.Bytes())
		flusher.Flush() // 不 flush 客户端就收不到——最常见的"流式变卡死"原因
	}
}

今日任务 ​

  • [ ] 给转发链路加流式分支:请求体 stream: true 时走 streamProxy,curl 验证 token 逐块到达(curl -N 观察渐进入场)
  • [ ] httptest 写"假上游"(分块 SSE + sleep),给透传写测试:块序一致、客户端断连后上游请求被取消
  • [ ] 真实联调:Phase 6 的 Next.js chat 前端把 base URL 指向 http://localhost:8080/v1,确认打字机效果正常——这是本周最有成就感的时刻
  • [ ] (P1 加分) Anthropic 上游的 SSE 事件格式转换(解析其事件 → 重打包为 openai chunk)

自检清单 ​

  • [ ] 我能说清"忘调 Flush"的现象与原因
  • [ ] 客户端中途关浏览器,我的网关会发生什么(上游请求是否被取消?验证过)
  • [ ] 我知道 scanner 默认缓冲为什么可能不够、怎么调

Day 14(周四):令牌桶限流 ​

学习目标: 用 x/time/rate 实现按客户端的限流中间件,理解令牌桶模型。

核心概念 ​

  • 令牌桶(token bucket): 桶里以固定速率生成令牌,请求取令牌、没令牌就拒绝/等待。允许突发(桶有容量)又限平均速率——比固定窗口计数更适合 LLM 网关(长请求、突发批处理多)。
  • golang.org/x/time/rate: rate.NewLimiter(rate.Limit(qps), burst);Allow() 非阻塞判断(超限直接 429)、Wait(ctx) 阻塞等令牌。网关入口用 Allow(),你自己调下游要礼貌时用 Wait。
  • 限流键的选择: MVP 按 API Key 或 IP 限(每键一个 limiter,sync.Map 或带锁 map 管理)。对照反模式库 #17 的 Python 多级限流——同一思想,Go 实现。
  • 429 要带 Retry-After: 告诉客户端何时能重试,这是公共 API 的礼貌。

代码示例 ​

go
// 按键限流中间件:每 key 一个 limiter,超限 429
type limiterMap struct {
	mu       sync.Mutex
	limiters map[string]*rate.Limiter
}

func (m *limiterMap) get(key string) *rate.Limiter {
	m.mu.Lock()
	defer m.mu.Unlock()
	l, ok := m.limiters[key]
	if !ok {
		l = rate.NewLimiter(5, 10) // 每秒 5 个,突发容量 10
		m.limiters[key] = l
	}
	return l
}

func withRateLimit(m *limiterMap, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		key := clientKey(r) // API Key 优先,退回 IP
		if !m.get(key).Allow() {
			w.Header().Set("Retry-After", "1")
			http.Error(w, `{"error":"rate limited"}`, http.StatusTooManyRequests)
			return
		}
		next.ServeHTTP(w, r)
	})
}

今日任务 ​

  • [ ] 实现限流中间件并挂到转发端点前,参数(QPS/burst)从配置读
  • [ ] 写并发测试:50 个并发请求打限流后的端点,断言"成功数 ≤ burst + qps×时长",-race 通过
  • [ ] (P1 加分) 限流键维度加"模型级"配额:贵模型单键 QPS 更低(路由表里带每模型限额)

自检清单 ​

  • [ ] 我能向面试官解释令牌桶 vs 滑动窗口 vs 固定窗口的取舍
  • [ ] 我的 limiter map 会无限增长吗(键空间是什么、要不要清理)
  • [ ] 429 响应带了 Retry-After 吗

Day 15(周五):超时与重试 + 周验收 ​

学习目标: 给转发链路戴上最后两道护栏,完成网关 MVP 的端到端验收。

核心概念 ​

  • 超时要分级: 客户端→网关(服务器的 ReadTimeout/WriteTimeout 或每请求 ctx)、网关→上游(context.WithTimeout 包 client.Do)。流式请求的超时语义不同:不是整体超时,而是"首字节超时 + 空闲块超时"——整体超时会杀掉合法的长对话。
  • 重试只做幂等安全区: 429(带退避)和 5xx 可重试;流式请求一旦开始吐块绝不重试(客户端已经收到半截响应,重试会重复计费+输出错乱)。重试次数 2 次封顶 + 指数退避(Phase 1 tenacity 的思想,Go 手写 20 行)。
  • 降级路径: 上游连续失败时可返回缓存的历史响应(Day 16 之后)或明确错误体。MVP 做到"明确错误体"即可。

今日任务(周验收) ​

  • [ ] 非流式转发包 context.WithTimeout(配置化,默认 30s);http.Server 设置 ReadHeader/Write 超时
  • [ ] 实现 429/5xx 重试(指数退避,最多 2 次),流式响应绝不重试——用假上游验证两种行为
  • [ ] 端到端验收: Phase 6 chat 前端 → 网关 → 两个厂商,流式对话 + 切换模型 + 故意触发限流(快速连发)三条路径全通
  • [ ] README 更新:本周完成项打勾,架构图补上流式与限流模块;go test -race ./... + golangci-lint run 全绿

自检清单 ​

  • [ ] 我能解释流式请求为什么不能整体超时、什么是首字节/空闲块超时
  • [ ] 我能说出重试的三条安全边界(哪些错误、几次、什么时候绝不能重试)
  • [ ] 三条验收路径录屏或截图留证——Week 4 README 要用

Week 4:缓存 + 可观测 + 部署 + 压测 ​

本周目标: MVP → 作品:缓存、结构化日志、Docker 上线、压测数据、README 本周产出: 上线的网关 + 压测报告 + 可讲 10 分钟的项目故事

Day 16(周一):响应缓存 ​

学习目标: 给非流式请求加精确缓存,理解"什么能缓存、什么绝不能"。

核心概念 ​

  • 缓存键 = 模型 + messages + 关键参数的哈希: sha256(model + sorted(params) + messages_json)。参数不进哈希会导致"temperature=0.9 命中 temperature=0 的缓存"这类隐蔽 bug。
  • 只缓存非流式、只缓存 temperature=0 类确定性请求: 流式响应缓存是另一个量级的工程(要存整条流再回放),MVP 不做;temperature 高的请求缓存命中意义也存疑(同样的输入你未必想要同样的输出)。
  • TTL 与容量: 进程内 LRU + TTL 足够 MVP(hashicorp/golang-lru 或手写 map + 过期清理)。分布式缓存(Redis)是上线后的扩展题。
  • 命中响应要打标: 响应头加 X-Cache: hit/miss,压测和调试都需要它。

今日任务 ​

  • [ ] 实现非流式响应缓存:哈希键 + TTL + 命中率统计,挂进转发链路(查缓存 → 未命中才转发)
  • [ ] 测试:相同请求两次,第二次命中(响应头可见 X-Cache: hit);改一个参数后未命中
  • [ ] (P1 加分) temperature=0 才缓存的开关逻辑;给缓存加最大条目数

自检清单 ​

  • [ ] 我能说出"哪些请求绝不缓存"及理由
  • [ ] 我的缓存键把所有影响输出的参数都算进去了吗(逐一核对)
  • [ ] 缓存会不会把 429/5xx 的错误响应也存下来(不会才对)

Day 17(周二):可观测性——slog 与请求指标 ​

学习目标: 结构化日志 + 请求级指标,让网关可调试、可汇报。

核心概念 ​

  • slog 是标准库结构化日志: slog.Info("chat_done", "model", m, "latency_ms", ms, "tokens", n)——JSON 日志、带字段,对标你 Phase 1 的 structlog。print 日志在 Phase 1 就是反模式(反模式库 #1),Go 同理。
  • 日志要带请求 ID: 中间件生成/透传 X-Request-ID,塞进 context,每条日志都带——排查一次"客户端说超时了"的故障你就会感谢它。
  • 指标最小集: 请求数、状态码分布、P50/P95 延迟、缓存命中率、上游错误数。MVP 用 expvar 或简单原子计数 + /metrics 端点暴露;Prometheus 接入是上线后扩展。
  • 什么该记日志: 请求开始/结束(模型、耗时)、上游错误、限流触发、缓存命中。请求体和响应内容默认不记(隐私 + 体量),调试时开 debug 级别。

今日任务 ​

  • [ ] 全局日志换成 slog,JSON 输出到 stdout;请求日志带 request_id、model、状态码、耗时
  • [ ] 中间件统一生成/透传 request_id;上游错误、限流触发、缓存命中三类事件各有一条日志
  • [ ] 加 /metrics 端点:总请求数、429 数、上游 5xx 数、缓存命中率(原子计数即可)
  • [ ] 用日志复现一个故障:故意停掉上游服务,从日志链定位"哪一层先发现的问题"

自检清单 ​

  • [ ] 出一条错误日志时,我能靠日志字段还原完整请求链路吗
  • [ ] 日志里有没有意外记录 API Key 或用户消息内容
  • [ ] 我能解释结构化日志相对 print 的价值(不是背书,说自己的话)

Day 18(周三):Docker 多阶段构建与部署上线 ​

学习目标: 把网关打成几 MB 的镜像并部署到公网——体会 Go 部署相对 Python 的降维优势。

核心概念 ​

  • 多阶段构建: 第一阶段 golang:1.x-alpine 编译出静态二进制(CGO_ENABLED=0),第二阶段 FROM scratch(或 alpine/distroless)只拷贝二进制。产物 5-10MB,对比 Python 镜像动辄几百 MB。
  • scratch 的代价: 没有 shell、没有 CA 证书——需要 HTTPS(调上游必须)就 COPY --from=builder /etc/ssl/certs/ca-certificates.crt。这个坑自己踩一遍才算会。
  • 部署平台复用 Phase 6 经验: Fly.io / Railway 对 Go 二进制几乎零配置。健康检查端点(/healthz)今天顺手加上。
  • 配置全走环境变量: 镜像里不含任何密钥(反模式库 #3 的容器版)。

今日任务 ​

  • [ ] 写多阶段 Dockerfile:编译 → scratch/alpine → 拷贝证书与二进制,本地 docker run 跑通流式对话
  • [ ] 部署到 Fly.io 或 Railway,公网端点可用;Phase 6 前端把 base URL 指到线上网关再联调一次
  • [ ] 加 /healthz 健康检查;确认镜像里 grep 不到任何密钥
  • [ ] (P1 加分) GitHub Actions:push 自动跑 lint + race 测试 + 构建镜像

自检清单 ​

  • [ ] 我的镜像多大?为什么能这么小(能说出 scratch/静态编译两层原因)
  • [ ] 容器重启后网关配置从哪来(环境变量,不是文件,不是镜像层)
  • [ ] 线上端点的流式对话验证过了吗(部署 ≠ 可用)

Day 19(周四):压测与性能优化 ​

学习目标: 用数据量化网关性能,做一处有前后对比的优化——面试故事的弹药库。

核心概念 ​

  • 压测工具 hey: hey -n 1000 -c 50 -z 30s https://...。压非流式端点(SSE 长连接会把并发数语义搅乱,流式优化看 P95 首字节时间,那是另一套方法)。
  • 看什么指标: QPS、P50/P95/P99 延迟、错误率。先测基线,再优化,再复测——没有 baseline 的"优化"是玄学(Phase 2 评测驱动思想的性能版)。
  • Go 侧常见优化点(按性价比排序): 复用 http.Client 并调连接池参数(MaxIdleConnsPerHost)→ 响应体 io.Copy 零拷贝透传(别读进内存再写)→ pprof 找热点(net/http/pprof 一行注册,go tool pprof 分析——今天见识即可,深挖是以后的事)。
  • 压测会烧 token: 上游是真实 LLM API 时压测就是烧钱。压测打的是你的网关 + mock 上游(httptest 假上游),测的是网关自身的开销与并发能力,不测厂商。mock 上游压测 + 少量真实请求功能验证,两层分开。

今日任务 ​

  • [ ] 起一个 mock 上游(固定延迟 100ms、返回固定响应),hey 打出并发梯度基线:c=1/10/50 三档,记录 QPS 与 P50/P95
  • [ ] 做一处优化(推荐连接池参数 + 确认 io.Copy 透传),同梯度复测,记录前后对比表
  • [ ] 给网关打上 pprof 端点,抓一份 CPU profile 看看热点在谁身上(哪怕看不懂细节,把工具链跑通)
  • [ ] 把数据表写进 README 的压测小节

自检清单 ​

  • [ ] 我的压测数据是在 mock 上游上采的(不是烧厂商 token 采的)
  • [ ] 有一处优化有前后两组数字(这就是面试里"你做过什么性能优化"的答案)
  • [ ] 我能说出 P95 比 QPS 更值得看的原因

Day 20(周五):README 作品集化 + 阶段复盘 ​

学习目标: 把四周工作收拢成一个能讲 10 分钟的作品集项目;按约定回填耗时数据。

核心概念 ​

作品集 README 的骨架(对标 Phase 6 的作品集标准):

  1. 一句话定位:openai 兼容的 LLM 网关,多厂商路由 + SSE 透传 + 限流 + 缓存
  2. 架构图(Day 11 那张的完成版)
  3. 快速开始:docker run 三行跑起来
  4. 设计取舍:为什么手写转发不用 ReverseProxy、为什么缓存只做非流式、为什么流式不重试、channel vs 锁的选型——每条都有"为什么"
  5. 压测报告:梯度表 + 优化前后对比
  6. Roadmap:语义缓存、成本核算、fallback 路由、Prometheus 接入

讲故事的角度: "为了搞懂 Agent 基建为什么用 Go,我从零写了一个 LLM 网关:X QPS、P95 Y ms、Docker 镜像 Z MB。最深的体会是 Go 的并发模型和 asyncio 的本质差异……"

今日任务 ​

  • [ ] README 按骨架补全:架构图、快速开始、设计取舍、压测表、Roadmap
  • [ ] 项目仓库收尾:.golangci.yml、CI(如有)、清晰的 commit 历史(前后周的分阶段提交今天补齐)
  • [ ] 阶段复盘: 复制复盘模板写 Phase 7 总结;把实测周数回填到阶段文档的耗时参考表,"预估"改"实测"
  • [ ] 自检阶段文档的完成标准清单,逐项勾选;给网关接进 Phase 6 作品集项目(前端 → 网关 → 厂商)拍一张架构全景图

自检清单 ​

  • [ ] 我能用 3 分钟把这项目讲清楚(对着 README 自己讲一遍并录音回听)
  • [ ] README 里每一条设计取舍都有"为什么"而不是"做了什么"
  • [ ] 耗时参考表已回填实测数据——路线文档因你而更准了一格