langchaingo 的 httprr 测试利器:HTTP 录制回放机制完整实战指南
【免费下载链接】langchaingoLangChain for Go, the easiest way to write LLM-based programs in Go项目地址: https://gitcode.com/GitHub_Trending/la/langchaingo
本篇指南围绕 LangChain for Go(langchaingo)仓库中的internal/httprr包展开,深入讲解其 HTTP 录制(Record)与回放(Replay)测试机制:如何在开发阶段记录真实的外部 LLM API 交互,如何在 CI 与日常测试中离线回放响应,从而获得确定性、快速且不依赖密钥的测试执行。读完本文,你将掌握httprr的全部核心 API、命令行录制流程、敏感信息脱敏(Scrubbing)、.httprr文件管理与压缩策略,以及从旧 API 迁移的方法,并能在自己的 Go 测试中直接复现这套实践。
httprr 是什么:为 LLM 测试而生的确定性 HTTP 录制回放
httprr(HTTP Record and Replay)位于 internal/httprr/,它实现了一个实现了http.RoundTripper接口的录制/回放器。其核心思想非常简单:开发时用真实凭证发起真实请求并落盘记录;测试或 CI 阶段不再联网,而是根据请求精确匹配并重放之前记录的响应。
它在 langchaingo 中的作用举足轻重:仓库里几乎所有需要调用外部 AI 服务(OpenAI、Google AI、Cohere、Mistral、Bedrock 等)的测试,都通过它来保证既不泄露密钥、也不受网络抖动影响。例如 chains/llm_test.go 中TestLLMChain先调用httprr.SkipIfNoCredentialsAndRecordingMissing(t, "OPENAI_API_KEY"),再用httprr.OpenForTest获取录制器,并且只在回放模式下才启用t.Parallel()以避免录制时触发限流。
从包注释(internal/httprr/rr.go)可以看到,该包源自 Go 官方测试工具链的适配版本,并为 langchaingo 增加了便于测试的便利函数。
核心概念:录制模式与回放模式
httprr通过-httprecord命令行标志决定工作模式(标志仅在go test构建的测试程序中注册,见 internal/httprr/rr.go 的init()):
- 录制模式(
-httprecord=.):发起真实 HTTP 请求,将(请求、响应)对保存到.httprr文件。任何匹配该正则表达式的测试文件都会被重新录制。 - 回放模式(默认):不发起网络请求,从已保存的
.httprr文件中按请求逐字节匹配并返回缓存响应;找不到匹配项时返回cached HTTP response not found错误。
完整的命令行标志
| 标志 | 默认值 | 说明 |
|---|---|---|
-httprecord=<regexp> | 空字符串 | 对文件名匹配该正则的测试重新录制(.匹配全部) |
-httprecord-delay=<duration> | 0(实际生效为 1s,见下) | 录制时每次请求之间的间隔,帮助规避限流 |
-httprecord-debug | false | 输出 httprr 录制细节的调试日志 |
-httpdebug | false | 输出 HTTP 请求/响应流量的完整 dump 日志 |
值得注意的是源码中的一个细节:虽然标志默认值是 0,但在录制模式下RoundTrip发现recordDelay == 0时会默认改为 1 秒再time.Sleep(见 internal/httprr/rr.go)。也就是说,录制本身就内置了 1 秒的限流保护,-httprecord-delay用于进一步拉大间隔。
文件管理策略
- 录制:始终创建未压缩的
.httprr文件,便于调试时直接阅读。 - 回放:自动兼容
.httprr与.httprr.gz两种文件;若两者同时存在,findBestReplayFile会选择修改时间较新的那个(internal/httprr/rr.go),并打印提示日志。 - 自动清理:录制模式下
cleanupExistingFiles会先删除同名及.gz旧文件,避免冲突(internal/httprr/rr.go)。
快速开始:5 行代码接入录制回放
func TestMyAPI(t *testing.T) { // Skip test gracefully if no credentials and no recording exists httprr.SkipIfNoCredentialsOrRecording(t, "API_KEY") // Create recorder/replayer rr, err := httprr.OpenForTest(t, http.DefaultTransport) if err != nil { t.Fatal(err) } defer rr.Close() // Use rr.Client() for all HTTP calls client := rr.Client() resp, err := client.Get("https://api.example.com/data") // ... test continues }这段代码蕴含了完整的工作流:没有凭证且没有录音时跳过;有凭证时打开录制器;rr.Client()返回的http.Client的 Transport 就是rr本身(等价于&http.Client{Transport: rr},见 internal/httprr/rr.go),因此所有经由该 client 的请求都会被录制或回放。
API 参考:核心函数与 RecordReplay 方法
OpenForTest(t *testing.T, rt http.RoundTripper) *RecordReplay
这是绝大多数测试的首选入口(注意:README 中写作返回(*RecordReplay, error),但当前源码实际签名是*RecordReplay,错误通过t.Fatal内部处理,见 internal/httprr/rr.go)。其行为细节:
- 录制模式:在
testdata/TestName.httprr创建新录音,文件名由t.Name()自动推导,目录固定为testdata/。 - 回放模式:加载已存在的录音文件(自动选择
.httprr或.httprr.gz中较新的版本)。 rt参数可选:传nil时自动回退到httputil.DefaultTransport(见 httputil/transport.go);也可以传入自定义*http.Transport,例如&http.Transport{MaxIdleConns: 10}。- 短模式保护:如果
-test.short生效且处于录制模式,测试会直接跳过录制(internal/httprr/rr.go),避免 CI 短跑误发真实请求。 - 内部通过
t.Cleanup自动关闭录制器。
SkipIfNoCredentialsOrRecording(t *testing.T, envVars ...string)
在"所需环境变量均未设置且不存在已有录音"时优雅跳过测试。需要指出:README 中这个名称对应的源码实现名为SkipIfNoCredentialsAndRecordingMissing(internal/httprr/rr.go),功能一致——先检查testdata/TestName.httprr(含.gz)是否存在,再检查任一环境变量是否设置,两者都没有则t.Skip并给出"用-httprecord=.重新录制"的提示。
// Skip if OPENAI_API_KEY not set AND no recording exists httprr.SkipIfNoCredentialsOrRecording(t, "OPENAI_API_KEY") // Skip if neither API_KEY nor BACKUP_KEY is set AND no recording exists httprr.SkipIfNoCredentialsOrRecording(t, "API_KEY", "BACKUP_KEY")Open(file string, rt http.RoundTripper) (*RecordReplay, error)
底层 API,用于完全自定义文件路径与传输层。它会根据-httprecord标志是否匹配file决定创建新日志还是打开旧日志(internal/httprr/rr.go)。大多数测试应使用OpenForTest。
RecordReplay方法一览
| 方法 | 说明 |
|---|---|
Client() *http.Client | 返回以rr为 Transport 的 HTTP 客户端 |
ScrubReq(scrubs ...func(*http.Request) error) | 追加请求脱敏函数(累加而非替换),在请求被用作查找键或写日志前按注册顺序调用 |
ScrubResp(scrubs ...func(*bytes.Buffer) error) | 追加响应脱敏函数,作用于响应的字节表示 |
Recording() bool | 是否处于录制模式(内部判断record文件句柄是否为 nil,见 internal/httprr/rr.go) |
Replaying() bool | 是否处于回放模式,即!Recording() |
Close() error | 关闭录制器;回放模式下为 no-op,录制模式下关闭底层文件并返回写入错误 |
rr.ScrubReq(func(req *http.Request) error { req.Header.Set("Authorization", "Bearer test-api-key") return nil })使用模式:从单测到多 API 集成
模式一:基础 API 测试(真实项目写法)
这是 langchaingo 各模块测试的标准范式,以 OpenAI 聊天测试为例(README 示例与 chains/llm_test.go 高度一致):
func TestOpenAIChat(t *testing.T) { httprr.SkipIfNoCredentialsOrRecording(t, "OPENAI_API_KEY") rr, err := httprr.OpenForTest(t, http.DefaultTransport) if err != nil { t.Fatal(err) } defer rr.Close() // Scrub sensitive data rr.ScrubReq(func(req *http.Request) error { req.Header.Set("Authorization", "Bearer test-api-key") return nil }) // Create client with recording support llm, err := openai.New(openai.WithHTTPClient(rr.Client())) require.NoError(t, err) // Test continues with recorded/replayed HTTP calls response, err := llm.GenerateContent(ctx, messages) require.NoError(t, err) }关键在于openai.WithHTTPClient(rr.Client())这类选项函数——langchaingo 所有 LLM 客户端(OpenAI、Google AI、SerpAPI、Cohere 等)都支持注入自定义 HTTP 客户端,这使httprr能无缝拦截所有底层调用。
模式二:辅助函数复用测试设置
把「跳过判断 + 打开录制器 + 注册清理」封装成 helper,多个测试共享:
func createTestClient(t *testing.T) *MyAPIClient { t.Helper() httprr.SkipIfNoCredentialsOrRecording(t, "MY_API_KEY") rr, err := httprr.OpenForTest(t, http.DefaultTransport) if err != nil { t.Fatal(err) } t.Cleanup(func() { rr.Close() }) return NewMyAPIClient(WithHTTPClient(rr.Client())) } func TestFeatureA(t *testing.T) { client := createTestClient(t) // ... test continues } func TestFeatureB(t *testing.T) { client := createTestClient(t) // ... test continues }注意这里用了t.Cleanup而非defer,这样即使测试函数提前 return 也能保证关闭。
模式三:多个 API 端点共用一份录音
一个测试中同时驱动多个外部服务时,让它们共享同一个rr.Client(),所有请求会按顺序写入同一份.httprr文件,回放时也按同样顺序匹配:
func TestMultiAPIIntegration(t *testing.T) { httprr.SkipIfNoCredentialsOrRecording(t, "OPENAI_API_KEY", "SERPAPI_KEY") rr, err := httprr.OpenForTest(t, http.DefaultTransport) if err != nil { t.Fatal(err) } defer rr.Close() // Both clients will use the same recording openaiClient := openai.New(openai.WithHTTPClient(rr.Client())) searchClient := serpapi.New(serpapi.WithHTTPClient(rr.Client())) // All HTTP calls are recorded/replayed together }命令行用法:如何录制与如何回放
录制新的交互
# Record all tests go test ./... -httprecord=. # Record specific test go test ./pkg -httprecord=. -run TestSpecificFunction # Record with pattern matching go test ./... -httprecord="TestOpenAI.*"-httprecord的值是正则表达式,对文件名进行匹配(而非测试名本身),.匹配所有文件。注意首次录制前需要配置好对应服务的 API 密钥环境变量,否则请求会失败。
用已有录音运行测试
# Normal test run (uses recorded data) go test ./... # Skip tests that need credentials OPENAI_API_KEY="" go test ./... # Tests will skip gracefully第二种写法模拟 CI 中"无密钥"场景:所有依赖 OPENAI_API_KEY 且无录音的测试都会被SkipIfNoCredentialsAndRecording优雅跳过,而不是报错失败。
录制限流保护
# Record with 1 second delay between requests go test -httprecord=. -httprecord-delay=1000 ./... # Record specific test with 500ms delay go test -httprecord=. -httprecord-delay=500 -run TestMyAPI ./mypackage如前述,即使不传该参数,录制时也已有 1 秒默认间隔兜底。
文件管理:命名规则、压缩与 rrtool
目录结构
testdata/ ├── TestBasicFunction.httprr # Uncompressed recording ├── TestWithSubtest-subcase.httprr # Subtest recording ├── TestOldFunction.httprr.gz # Compressed recording └── TestComplexAPI-setup.httprr # Multi-part test命名规则
文件名由测试名经CleanFileName(internal/httprr/rr.go)转换而来:
- 测试名
TestMyFunction→ 文件TestMyFunction.httprr - 子测试
TestMyFunction/subcase→TestMyFunction-subcase.httprr(/替换为-) - 特殊字符(
\ : * ? " < > |及空格)全部替换为-,连续多个-合并,首尾-去除
该函数的行为由 internal/httprr/rr_unit_test.go 中的 10 个用例覆盖,例如"Test API/Complex_Case"→"Test-API-Complex_Case"。
压缩管理:rrtool
README 给出了三个命令用于录音压缩管理:
# Compress all recordings (for repository storage) go run ./internal/devtools/rrtool pack -r # Check compression status go run ./internal/devtools/rrtool check # Decompress for debugging go run ./internal/devtools/rrtool unpack -r需要说明的是,从当前 internal/devtools/rrtool/main.go 的源码结构看,实际实现并注册了check与list-packages两个子命令,其中check递归扫描目录、找出所有未压缩的.httprr文件并报告(退出码非 0 表示存在未压缩文件),list-packages会解析 Go 源码、扫描所有 import 了 httprr 的包并输出包路径(支持-format command直接生成go test -httprecord=. <pkgs>命令)。而pack/unpack/clean目前仅出现在帮助文本中。仓库的录音文件大多以.httprr.gz形态提交(例如 llms/openai/ 与 chains/testdata/ 下的测试数据),说明提交前压缩是仓库的既定约定。
最佳实践:来自源码的安全性与可维护性设计
1. 始终使用优雅跳过
// ✅ Good: Test skips gracefully when it can't run httprr.SkipIfNoCredentialsOrRecording(t, "API_KEY") // ❌ Bad: Test fails when API key missing rr, err := httprr.OpenForTest(t, http.DefaultTransport)2. 脱敏敏感数据
// ✅ Good: Replace real API keys with test values rr.ScrubReq(func(req *http.Request) error { req.Header.Set("Authorization", "Bearer test-api-key") return nil }) // ❌ Bad: Real API keys recorded in files // (No scrubbing - keys end up in repository)3. 使用辅助函数消除重复设置
// ✅ Good: Reusable test setup func createTestLLM(t *testing.T) *openai.LLM { t.Helper() httprr.SkipIfNoCredentialsOrRecording(t, "OPENAI_API_KEY") // ... setup code } // ❌ Bad: Duplicate setup in every test func TestA(t *testing.T) { httprr.SkipIfNoCredentialsOrRecording(t, "OPENAI_API_KEY") rr, err := httprr.OpenForTest(t, http.DefaultTransport) // ... repeated setup }4. 正确管理清理
// ✅ Good: Automatic cleanup defer rr.Close() // or t.Cleanup(func() { rr.Close() }) // ❌ Bad: Manual cleanup (can be forgotten) // (No defer or cleanup)5. 录制后的并行策略
实际项目中还有一条来自 chains/llm_test.go 的经验:仅在回放模式下启用t.Parallel()。因为录制时要发真实请求,并行会放大限流风险;回放时不联网,并行安全且提速。
故障排查
"cached HTTP response not found"
问题:测试尝试发起的 HTTP 请求不在录音文件中(可能是新加了请求路径、请求头或 body 发生了变化)。
解决:
# Re-record the test go test ./pkg -httprecord=. -run TestName # Check if you have required environment variables export OPENAI_API_KEY="your-key-here" go test ./pkg -httprecord=. -run TestName回放失败的报错信息本身也会给出提示(internal/httprr/rr.go):cached HTTP response not found for: <请求内容>,并建议用-httprecord=.重新录制,同时提示-httprecord-debug与-httpdebug两个调试标志。
"gzip: invalid header"
问题:.httprr.gz文件损坏,或实际内容并未被 gzip 压缩。
解决:
# Check and fix compression go run ./internal/devtools/rrtool check go run ./internal/devtools/rrtool pack -r # Or remove the corrupted file and re-record rm testdata/TestName.httprr.gz go test ./pkg -httprecord=. -run TestName测试意外跳过
问题:期望运行却跳过了。
调试步骤:
# Check if environment variables are set echo $OPENAI_API_KEY # Check if recording exists ls testdata/TestName.httprr* # Run with verbose output go test ./pkg -run TestName -v文件冲突
系统会自动处理,也可手动解决:
# Check which file is newer ls -la testdata/TestName.httprr* # Remove older file (system will warn and use newer) rm testdata/TestName.httprr.gz # if .httprr is newer # Or compress the newer one gzip testdata/TestName.httprr迁移指南:从旧 API 到新 API
OpenForTestWithSkip旧 API 已被移除,迁移方式如下:
// ❌ Old API (removed) rr := httprr.OpenForTestWithSkip(t, http.DefaultTransport, "API_KEY") defer rr.Close() // ✅ New API httprr.SkipIfNoCredentialsOrRecording(t, "API_KEY") rr, err := httprr.OpenForTest(t, http.DefaultTransport) if err != nil { t.Fatal(err) } defer rr.Close()新 API 的优势:
- 一致的错误处理:所有 httprr 操作统一返回错误
- 关注点分离:跳过逻辑与文件操作完全解耦
- 单一职责:每个函数只有一个清晰目的
- 自文档化:函数名即用法说明
高级用法与源码原理
自定义文件位置(底层 API)
// For custom file management (rarely needed) rr, err := httprr.Open("custom/path/recording.httprr", http.DefaultTransport) if err != nil { t.Fatal(err) } defer rr.Close()条件录制:有密钥就录,没密钥就回放
func TestWithConditionalRecording(t *testing.T) { // Only record if we have credentials if os.Getenv("API_KEY") != "" { // Will record new interactions rr, err := httprr.OpenForTest(t, http.DefaultTransport) // ... } else { // Will only replay existing recordings httprr.SkipIfNoCredentialsOrRecording(t, "API_KEY") rr, err := httprr.OpenForTest(t, http.DefaultTransport) // ... } }复杂脱敏:请求体与响应体
ScrubReq中当req.Body != nil时,其类型保证为*httprr.Body,可以直接读写内部Data字段:
rr.ScrubReq(func(req *http.Request) error { // Remove API keys req.Header.Set("Authorization", "Bearer test-key") // Scrub request body if req.Body != nil { body := req.Body.(*httprr.Body) bodyStr := string(body.Data) bodyStr = strings.ReplaceAll(bodyStr, "real-secret", "test-secret") body.Data = []byte(bodyStr) } return nil }) rr.ScrubResp(func(buf *bytes.Buffer) error { // Remove sensitive data from responses content := buf.String() content = strings.ReplaceAll(content, "sensitive-data", "redacted") buf.Reset() buf.WriteString(content) return nil })内置默认脱敏器:安全第一的设计
httprr在create/open时自动注册默认 scrubber(internal/httprr/rr.go),因此即使你不写任何ScrubReq,以下内容也会被自动净化:
请求侧:
- 头部名称包含
api-key、api-token、token或authorization的,值一律替换为test-api-key;Authorization会保留认证类型前缀(如Bearer test-api-key) - URL 查询参数中名称含
api_key、api-key、api-token、token、key的值替换为test-api-key Openai-Organization替换为lcgo-tstUser-Agent统一规范为langchaingo-httprrx-goog-api-client、x-amz-user-agent、x-ms-client-request-id等携带版本信息的头部做版本规范化
响应侧:
- 移除
Cf-Ray(Cloudflare 追踪头)与Set-Cookie Openai-Organization替换为lcgo-tst
这些行为由 internal/httprr/rr_unit_test.go 的TestDefaultRequestScrubbers/TestDefaultResponseScrubbers直接验证,例如Authorization: Bearer secrettoken会被改写为Bearer test-api-key。
版本规范化:让录音不随依赖升级失效
一个非常有价值的设计是版本头规范化。例如 Google AI 客户端的x-goog-api-client携带库版本(gl-go/1.24.4 gccl/v0.15.1 ...),一旦依赖升级,录音中的请求字符串变化会导致回放失配。normalizeGoogleAPIClientHeader会把版本替换为占位符(gl-go/X.XX.X gccl/vX.XX.X ...),并保持字节长度完全一致(不足补空格、超出截断),从而保证录音完整性。相应测试见 internal/httprr/normalization_test.go。此外,序列化阶段还会移除openai-project头、统一User-Agent(internal/httprr/rr.go)。
.httprr文件格式:trace v1
回放时open函数解析文件(internal/httprr/rr.go),格式为:
httprr trace v1 <n1> <n2> <请求字节串,长度 n1><响应字节串,长度 n2>每对(请求、响应)以一行n1 n2开头,随后是 n1 字节请求与 n2 字节响应。请求串由reqWire通过req.WriteProxy序列化(保留 URL scheme),响应串由respWire序列化。rr_test.go中的TestRecordReplay用 4 轮 pass 完整验证了 create → open → re-record → replay 的生命周期,并断言最终文件中不包含Secret字样(internal/httprr/rr_test.go)。回放时若 trace 损坏(头不对、长度越界等),会得到not an httprr trace或corrupt httprr trace错误(internal/httprr/rr_test.go 覆盖了全部错误路径)。
Body类型:请求体可重复消费的关键
由于请求体需要既用于回放匹配键、又用于真实转发,httprr定义了可重读的Body类型(Data []byte+ReadOffset)。RoundTrip先读取并关闭原始 body,替换为*Body,reqWire消费一次后通过重置ReadOffset让真实 RoundTrip 再读一次(internal/httprr/rr.go)。该行为由TestBodyReadClose验证(internal/httprr/rr_unit_test.go)。
面向 embedding 测试的专项优化
OpenForEmbeddingTest(internal/httprr/rr.go)在OpenForTest基础上追加EmbeddingJSONFormatter响应脱敏器,该格式化器会把 embedding API 响应中的浮点数组压缩为单行(如[1, 2, 3.5]),显著减小大向量 JSON 录音的体积。它只对 OpenAI embeddings、batchEmbedContents、models/embedding-等已知 embedding 端点生效(conditionalEmbeddingFormatter),相关单测见 internal/httprr/rr_unit_test.go。
为仓库贡献新测试时的约定
当你在 langchaingo 中新增需要外部 API 的测试时:
- 始终使用
SkipIfNoCredentialsOrRecording实现优雅降级 - 加入适当的脱敏,避免密钥进入版本库(默认脱敏器会兜底,但业务敏感字段需自行 scrub)
- 先用真实凭证录制,随后检查并脱敏结果
- 提交前压缩录音,节省仓库空间(录音文件通常放在各包的
testdata/目录,如 chains/testdata/、llms/openai/) - 在测试注释中说明所需的环境变量(参考 chains/llm_test.go 的写法)
总结
httprr为 langchaingo 这类重度依赖外部 LLM API 的 Go 项目提供了"一次录制、处处回放"的确定性测试范式:录制阶段保证真实性与完整性(含限流保护、自动脱敏、版本规范化),回放阶段保证速度与可复现性(离线、无密钥、可并行)。无论是为已有服务补测试,还是为新的 LLM 集成编写测试,这套「跳过判断 + OpenForTest + Scrub + 压缩」的组合拳都是 langchaingo 各模块测试的通用模板,值得在你的项目中直接复用。
【免费下载链接】langchaingoLangChain for Go, the easiest way to write LLM-based programs in Go项目地址: https://gitcode.com/GitHub_Trending/la/langchaingo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考