主题
第 7 阶段每日计划:Go 与高性能 Agent 服务
周期: 4 周(Phase 7 / Week 1-4,独立编号,非全局周号) 每日投入: 工作日约 1-1.5 小时 / 周末 2-3 小时 总产出物: Agent 网关(多厂商路由 + SSE 流式 + 限流)上线,可压测、有 README
学习方法(本阶段专属)
- 双锚点对比 — 你已会 Python 和 TS,每学一个 Go 概念先问"Python 里对应什么?TS 里呢?为什么设计不同"。差异处就是理解增长处。
- 并发是语言不是库 — 本阶段认知核心。不要用"背 API"的方式学 goroutine/channel,用"和 asyncio 对比"的方式学。
- 项目驱动 — Week 3 起所有代码都为网关服务。每学一个新特性,先问"网关里哪一块用得上它"。
- 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 重构为一个
Analyzerstruct:指针接收者方法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 各自的阻塞条件
- [ ] 我知道谁该
closechannel、双 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/echoPOST 端点: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 的作品集标准):
- 一句话定位:openai 兼容的 LLM 网关,多厂商路由 + SSE 透传 + 限流 + 缓存
- 架构图(Day 11 那张的完成版)
- 快速开始:
docker run三行跑起来 - 设计取舍:为什么手写转发不用 ReverseProxy、为什么缓存只做非流式、为什么流式不重试、channel vs 锁的选型——每条都有"为什么"
- 压测报告:梯度表 + 优化前后对比
- 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 里每一条设计取舍都有"为什么"而不是"做了什么"
- [ ] 耗时参考表已回填实测数据——路线文档因你而更准了一格