Week 1 Day 1:Go 项目骨架 - 苏格拉底教学

学习目标

完成一个可以运行的Go HTTP服务,理解:

  • Go module 和项目结构
  • HTTP middleware 的设计
  • Structured logging 的实现
  • Graceful shutdown 的必要性

第一部分:问题驱动学习

🤔 问题1:为什么Go项目要用module?

引导问题:

  1. 你用过Python的 pip install 吗?Go如何管理依赖?
  2. 如果你的代码同时依赖 lib-a 和 lib-b,而它们都需要不同版本的 dependency-c,怎么办?
  3. 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. 问题1: 没有结构化日志 → 怎么在生产环境查问题?

    • 添加 log/slog(Go 1.21+内置)
  2. 问题2: 直接用 http.HandleFunc 不够灵活 → 用Router

    • 选用轻量级 Router(chi或gin)
  3. 问题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 - 为什么重要

场景: 线上服务正在处理请求,突然收到关闭信号会怎样?

问题:

  1. 未处理完的请求会被中断
  2. 数据库连接没来得及关闭
  3. 工具调用进行到一半

解决方案:

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
}

第七部分:自测清单

运行前,问自己:

  • 我能解释为什么用module?
  • Middleware的执行链是什么样的?
  • Graceful shutdown会怎么工作?
  • 如果LLM调用超时,会发生什么?
  • 为什么Dockerfile是两阶段的?

第八部分:实战作业

任务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的代码

  • /health 端点返回 {"status": "ok"}
  • /chat 端点接收 {"message": "..."},返回 mock 回答
  • 所有请求都记录到日志(method、path、duration)
  • 优雅关闭实现(接收SIGINT/SIGTERM)

任务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.modgo.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)

题目: 给定两个字符串 st,判断 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 预告

明天我们会:

  1. 封装 OpenAI LLM Client
  2. 理解为什么要用 interface 而不是直接用 SDK
  3. 支持 request_id、timeout、model 配置

准备问题:

  • 为什么封装 LLM Client 而不是直接在 handler 里调用 OpenAI SDK?