langchaingo 的 httprr 测试利器:HTTP 录制回放机制完整实战指南
2026/9/16 19:17:30 网站建设 项目流程

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-debugfalse输出 httprr 录制细节的调试日志
-httpdebugfalse输出 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/subcaseTestMyFunction-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 的源码结构看,实际实现并注册了checklist-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 的优势:

  1. 一致的错误处理:所有 httprr 操作统一返回错误
  2. 关注点分离:跳过逻辑与文件操作完全解耦
  3. 单一职责:每个函数只有一个清晰目的
  4. 自文档化:函数名即用法说明

高级用法与源码原理

自定义文件位置(底层 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 })

内置默认脱敏器:安全第一的设计

httprrcreate/open时自动注册默认 scrubber(internal/httprr/rr.go),因此即使你不写任何ScrubReq,以下内容也会被自动净化:

请求侧

  • 头部名称包含api-keyapi-tokentokenauthorization的,值一律替换为test-api-keyAuthorization会保留认证类型前缀(如Bearer test-api-key
  • URL 查询参数中名称含api_keyapi-keyapi-tokentokenkey的值替换为test-api-key
  • Openai-Organization替换为lcgo-tst
  • User-Agent统一规范为langchaingo-httprr
  • x-goog-api-clientx-amz-user-agentx-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 tracecorrupt httprr trace错误(internal/httprr/rr_test.go 覆盖了全部错误路径)。

Body类型:请求体可重复消费的关键

由于请求体需要既用于回放匹配键、又用于真实转发,httprr定义了可重读的Body类型(Data []byte+ReadOffset)。RoundTrip先读取并关闭原始 body,替换为*BodyreqWire消费一次后通过重置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、batchEmbedContentsmodels/embedding-等已知 embedding 端点生效(conditionalEmbeddingFormatter),相关单测见 internal/httprr/rr_unit_test.go。

为仓库贡献新测试时的约定

当你在 langchaingo 中新增需要外部 API 的测试时:

  1. 始终使用SkipIfNoCredentialsOrRecording实现优雅降级
  2. 加入适当的脱敏,避免密钥进入版本库(默认脱敏器会兜底,但业务敏感字段需自行 scrub)
  3. 先用真实凭证录制,随后检查并脱敏结果
  4. 提交前压缩录音,节省仓库空间(录音文件通常放在各包的testdata/目录,如 chains/testdata/、llms/openai/)
  5. 在测试注释中说明所需的环境变量(参考 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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询