Week 1 Day 1:Go 项目骨架 - 苏格拉底教学
学习目标
完成一个可以运行的Go HTTP服务,理解:
- Go module 和项目结构
- HTTP middleware 的设计
- Structured logging 的实现
- Graceful shutdown 的必要性
第一部分:问题驱动学习
🤔 问题1:为什么Go项目要用module?
引导问题:
- 你用过Python的
pip install 吗?Go如何管理依赖?
- 如果你的代码同时依赖 lib-a 和 lib-b,而它们都需要不同版本的 dependency-c,怎么办?
- Go是否有全局的"site-packages"?
答案揭示:
- Go module (
go.mod) 是项目级的依赖管理
- 每个项目可以有自己的依赖版本
go.sum 确保可重复构建
你应该理解:
go mod init github.com/yourname/agent-runtime
# 这做了什么?
cat go.mod # 看看结构
第二部分:动手实现 - 层层递进
📝 第一层:项目骨架
问题: 一个Go项目应该长什么样?
传统建议(为什么?):
.
├── cmd/
│ └── api/
│ └── main.go # 启动点
├── internal/
│ ├── server/ # HTTP服务器逻辑
│ ├── llm/ # LLM相关
│ └── config/ # 配置
├── go.mod
├── go.sum
├── Dockerfile
└── README.md
反思题:
- 为什么要用
cmd/ 而不是直接 main.go?(如果后来有CLI工具呢?)
- 为什么要用
internal/?(Go的namespace隔离)
💻 第二层:HTTP服务器骨架
从最小化开始 - 先跑起来:
// cmd/api/main.go
package main
import (
"net/http"
"log"
)
func main() {
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
log.Fatal(http.ListenAndServe(":8080", nil))
}
问题: 这段代码能工作,但有什么问题?
逐步改进:
-
问题1: 没有结构化日志 → 怎么在生产环境查问题?
-
问题2: 直接用 http.HandleFunc 不够灵活 → 用Router
-
问题3: 服务器没有graceful shutdown → 会丢请求
第三部分:代码示例 - 递进式
✅ 版本1:只有/health
// cmd/api/main.go
package main
import (
"net/http"
"log"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
log.Fatal(http.ListenAndServe(":8080", mux))
}
测试:
go run cmd/api/main.go
# 另一个终端
curl http://localhost:8080/health
✅ 版本2:加入structured logging
为什么要slog?
- JSON格式日志易于解析
- 可以添加字段(request_id、latency等)
- 适合生产环境日志收集
// cmd/api/main.go
package main
import (
"log/slog"
"net/http"
"os"
)
func main() {
// 初始化日志
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)
mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
slog.Info("health check", "method", r.Method, "path", r.URL.Path)
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
slog.Info("server starting", "port", 8080)
slog.Error("server stopped", "error", http.ListenAndServe(":8080", mux))
}
问题: 每个handler里都要写日志,如何复用?→ Middleware!
✅ 版本3:Middleware模式
思考: 日志、超时、错误处理对所有endpoint都一样,怎么避免重复?
// internal/middleware/logging.go
package middleware
import (
"log/slog"
"net/http"
"time"
)
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// 继续处理请求
next.ServeHTTP(w, r)
// 记录耗时
duration := time.Since(start)
slog.Info("request",
"method", r.Method,
"path", r.URL.Path,
"duration_ms", duration.Milliseconds(),
)
})
}
使用:
// 用chi简化路由
import "github.com/go-chi/chi/v5"
import "github.com/go-chi/chi/v5/middleware"
router := chi.NewRouter()
router.Use(middleware.Logger)
router.Use(middleware.Recoverer)
router.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
问题: Middleware的执行顺序是什么?写出来试试看。
✅ 版本4:加入/chat(mock版本)
// internal/server/handler.go
package server
import (
"encoding/json"
"net/http"
)
type ChatRequest struct {
Message string `json:"message"`
}
type ChatResponse struct {
Answer string `json:"answer"`
}
func (s *Server) ChatHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req ChatRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request", http.StatusBadRequest)
return
}
// Mock 回答
resp := ChatResponse{
Answer: "This is a mock response to: " + req.Message,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
第四部分:Graceful Shutdown - 为什么重要
场景: 线上服务正在处理请求,突然收到关闭信号会怎样?
问题:
- 未处理完的请求会被中断
- 数据库连接没来得及关闭
- 工具调用进行到一半
解决方案:
package main
import (
"context"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"log/slog"
)
func main() {
srv := &http.Server{
Addr: ":8080",
Handler: router,
}
// 在goroutine中启动服务器
go func() {
slog.Info("starting server", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("server error", "error", err)
}
}()
// 捕获关闭信号
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
slog.Info("shutdown signal received")
// 给30秒时间优雅关闭
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
slog.Error("shutdown error", "error", err)
}
}
问题: 为什么要给30秒而不是立即关闭?
第五部分:Dockerfile - 让它可部署
# Dockerfile
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY . .
RUN go mod download
RUN CGO_ENABLED=0 go build -o api ./cmd/api
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/api .
EXPOSE 8080
CMD ["./api"]
问题: 为什么是两阶段构建?第一阶段和第二阶段分别做什么?
第六部分:关键Go概念
context.Context - 贯穿全栈的信号机制
问题: 如果LLM调用超时了,怎么让所有相关操作都停止?
// 有超时的请求
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// 传递给下游调用
result, err := llmClient.Generate(ctx, request)
你需要理解:
context.WithTimeout - 设置最大耗时
context.WithCancel - 手动取消
context.WithValue - 传递数据(如request_id)
错误处理 - Go的约定
Go风格(不是try/catch):
if err != nil {
slog.Error("operation failed", "error", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
问题: 这样的错误处理有什么缺点?→ 无法分类
改进: 定义错误类型
type ErrorType string
const (
ErrorTypeValidation ErrorType = "validation_error"
ErrorTypeInternal ErrorType = "internal_error"
ErrorTypeTimeout ErrorType = "timeout"
)
type APIError struct {
Type ErrorType
Message string
Err error
}
第七部分:自测清单
运行前,问自己:
第八部分:实战作业
任务1:建立项目
mkdir agent-runtime && cd agent-runtime
go mod init github.com/yourname/agent-runtime
mkdir -p cmd/api internal/{server,config,middleware}
任务2:完成版本4的代码
任务3:Docker验证
docker build -t agent-runtime .
docker run -p 8080:8080 agent-runtime
curl http://localhost:8080/health
任务4:深化思考
- 为什么 要分离 cmd 和 internal?
- 如何 才能让日志既清晰又高效?
- 什么时候 需要 middleware 而不是在 handler 里处理?
第九部分:关键概念总结
| 概念 |
为什么重要 |
Day 1 怎么用 |
| Go Module |
管理依赖版本 |
go.mod 和 go.sum |
| Middleware |
复用通用逻辑(日志、超时等) |
日志和错误处理 |
| context.Context |
传递取消信号和超时 |
HTTP handler的第一个参数 |
| Graceful Shutdown |
避免丢失请求 |
捕获信号,给时间关闭 |
| Error Handling |
Go风格的错误处理 |
if err != nil 模式 |
配套算法题
题1:Two Sum (Easy)
题目: 给定一个整数数组 nums 和一个目标值 target,找出数组中两个数的下标,使其和等于目标值。假设每种输入只有一个答案,且同一元素不能使用两次。
思路: 哈希表一次遍历。遍历时检查 target - nums[i] 是否已在 map 中,若在则直接返回两个下标;否则将当前值及其下标存入 map。避免了暴力O(n²)的双重循环。
func twoSum(nums []int, target int) []int {
m := make(map[int]int) // 值 → 下标
for i, n := range nums {
if j, ok := m[target-n]; ok {
return []int{j, i}
}
m[n] = i
}
return nil
}
复杂度: 时间 O(n),空间 O(n)
Agent 上下文联想: 哈希表是"以空间换时间"的核心思想,和 CachingClient 用 map 存响应如出一辙。
题2:Valid Anagram (Easy)
题目: 给定两个字符串 s 和 t,判断 t 是否是 s 的字母异位词(即包含完全相同的字母,顺序可以不同)。
思路A(排序法): 将两个字符串都排序,比较结果是否相等。简单直观,适合小字符串。
思路B(计数法): 用长度26的数组统计 s 中各字母出现次数,再遍历 t 做减法,最后检查数组全为零。只需一次遍历,效率更高。
// 排序法
import "sort"
func isAnagramSort(s, t string) bool {
if len(s) != len(t) {
return false
}
ss, ts := []byte(s), []byte(t)
sort.Slice(ss, func(i, j int) bool { return ss[i] < ss[j] })
sort.Slice(ts, func(i, j int) bool { return ts[i] < ts[j] })
return string(ss) == string(ts)
}
// 计数法(推荐)
func isAnagram(s, t string) bool {
if len(s) != len(t) {
return false
}
var count [26]int
for i := 0; i < len(s); i++ {
count[s[i]-'a']++
count[t[i]-'a']--
}
for _, c := range count {
if c != 0 {
return false
}
}
return true
}
复杂度:
- 排序法:时间 O(n log n),空间 O(n)
- 计数法:时间 O(n),空间 O(1)(固定26格)
题3:Group Anagrams (Medium)
题目: 给定一个字符串数组,将所有字母异位词分组在一起。
思路: 对每个字符串排序后作为哈希 key,相同 key 的字符串一定是异位词,用 map 分组收集。
import "sort"
func groupAnagrams(strs []string) [][]string {
m := make(map[string][]string)
for _, s := range strs {
// 排序后的字符串作为 key
key := sortString(s)
m[key] = append(m[key], s)
}
result := make([][]string, 0, len(m))
for _, group := range m {
result = append(result, group)
}
return result
}
func sortString(s string) string {
b := []byte(s)
sort.Slice(b, func(i, j int) bool { return b[i] < b[j] })
return string(b)
}
复杂度: 时间 O(n · k log k)(n 为字符串数,k 为最长字符串长度),空间 O(n · k)
Agent 上下文联想: 分组归类的思想类似 EvalRunner 里按 category/difficulty 分桶统计指标,key 设计是关键。
下一步:Day 2 预告
明天我们会:
- 封装 OpenAI LLM Client
- 理解为什么要用 interface 而不是直接用 SDK
- 支持 request_id、timeout、model 配置
准备问题:
- 为什么封装 LLM Client 而不是直接在 handler 里调用 OpenAI SDK?