175 行 Go 实现轻量 Claude 客户端
作者用 Go 标准库在单文件中实现 Claude 客户端,涵盖 HTTPS、JSON 和 SSE 流处理,并使用 scratch 容器运行。示例展示了按行读取流、筛选 data 字段及解析事件的实现方式。
AI 深度解读
用 Go 写一个能流式输出、调用工具、继续对话的 Claude 客户端,作者交出的实现只有一个文件、175 行代码,运行镜像以 scratch 为基础。文章真正有意思的地方,是把网络、JSON、时区、证书和运行时的成本都摆到了台面上。
作者测得冷启动不到 2 毫秒,整轮交互的客户端耗时不到 5 毫秒,但二进制仍有 5.8 MB。对想写 AI 命令行工具或独立服务的程序员,这个例子适合用来理解:最小客户端需要保留什么,又有哪些成本藏不掉。
流式输出不难,难的是把工具调用拼完整
SSE(Server-Sent Events)按行传输,包含 event:、data:、空行和偶尔出现的 ping。针对这套 Claude 流,作者只处理 data: 开头的行,再用 encoding/json 解码事件。
下面这段代码同时解决了实时打印文本、保存回复内容和拼接工具参数的问题:
sc := bufio.NewScanner(resp.Body)
sc.Buffer(make([]byte, 64*1024), 1<<20)
for sc.Scan() {
data, ok := strings.CutPrefix(sc.Text(), "data: ")
if !ok {
continue // event:、ping 内容、空白分隔行
}
var ev event
if err := json.Unmarshal([]byte(data), &ev); err != nil {
return nil, "", err
}
switch ev.Type {
case "content_block_start":
content = append(content, *ev.ContentBlock)
partial = append(partial, strings.Builder{})
case "content_block_delta":
switch ev.Delta.Type {
case "text_delta":
os.Stdout.WriteString(ev.Delta.Text)
content[ev.Index].Text += ev.Delta.Text
case "input_json_delta":
partial[ev.Index].WriteString(ev.Delta.PartialJSON)
}
case "content_block_stop":
if content[ev.Index].Type == "tool_use" {
if s := partial[ev.Index].String(); s != "" {
content[ev.Index].Input = json.RawMessage(s)
}
}
case "message_delta":
stop = ev.Delta.StopReason
}
}
要盯住第二行:bufio.Scanner 默认的 token 上限是 64 KB,大工具参数可能让单条 data: 超过限制。这里用 64*1024 设置初始缓冲区,用 1<<20 把上限提高到约 1 MB。它仍有边界,只是边界更符合这个任务。
事件结构也做了合并:顶层保留 type、index,下面放 delta 或 content_block,不同事件没用到的字段留零值。这样少写类型,代价是业务代码需要知道每种事件会携带哪些字段。
文本增量可以立即打印,工具参数却要等 input_json_delta 拼完,才能作为完整 JSON 使用。这是两种不同的数据处理节奏。
工具执行完,为什么还要回传模型自己的回复
工具调用的完整性,取决于下一次请求能否还原上一轮对话。
第一次流以 stop_reason: "tool_use" 结束后,客户端不能只发工具结果。第二次请求需要包含原始用户消息、助手的完整回复,以及承载 tool_result 的新用户消息。助手回复里的 tool_use 块也必须带上拼好的输入。
作者让同一个 block 结构体承担接收和发送,通过 json:"text,omitzero" 等标签省略零值字段。文本块输出 type、text;工具调用输出 type、id、name、input;工具结果输出 type、tool_use_id、content。
Input 使用 json.RawMessage 也很贴合这个任务:参数原先就是 JSON 文本,没有必要先解码成 map,再编码回去。真正执行工具时,再用只有一个字段的匿名结构体读取时区。
于是,最小 agent 循环就出来了:发送请求、打印输出;遇到 tool_use,执行所有工具块,追加助手消息和工具结果消息,再发一次请求。代码短,是因为它只保留了这条必要路径。
scratch 镜像很空,HTTPS 和时区仍要付账
scratch 没有完整系统环境。HTTPS 验证要用 CA 证书,time.LoadLocation("Europe/London") 要找时区数据库,两者都不能凭空出现。
作者用下面的 Dockerfile 把运行所需文件带进去:
FROM golang:1 AS build
WORKDIR /src
COPY client.go ./
RUN go mod init client && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /client client.go
FROM scratch
COPY --from=build /client /app/client
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=build /usr/local/go/lib/time/zoneinfo.zip /usr/local/go/lib/time/zoneinfo.zip
ENTRYPOINT ["/app/client"]
要注意两个 COPY:证书和 zoneinfo.zip 都是运行依赖。后者来自 Go 自带的 $GOROOT/lib/time/zoneinfo.zip,LoadLocation 可以回退到它。
另一种做法是 import _ "time/tzdata",把时区库嵌进二进制,约增加 450 KB。作者选择复制 zip,因为排行榜会统计产物成本。无论放哪,依赖都要算账。
测试环境是 go1.27.2、linux/arm64,在 Apple M4 Max 上通过 Docker 运行。构建只用了 CGO_ENABLED=0、-trimpath、-ldflags="-s -w",没有额外调优。
作者报告二进制为 5.8 MB,主要成本来自 net/http、它依赖的 crypto/tls,以及 encoding/json。文中还称,后者从 1.27 起底层使用 v2 实现。作为对照,作者预告的 .NET Native AOT 客户端二进制为 5.4 MB,另需承担 2.4 MB 的 C 库。
速度方面,从进程启动到首个请求发出不到 2 毫秒;第一次回复结束到第二次请求到达略高于 1 毫秒;整轮客户端耗时不到 5 毫秒。峰值内存约 6 MB,作者将其归因于 Go 运行时的堆 arena,并提到不同轮次会相差几 MB。
我的判断
这篇最有用的,是把「能打印模型回复」和「能完成工具对话」之间的代码补齐了。对前端、Node 开发者而言,事件名称可能熟悉,但完整保存助手回复、拼接工具输入、按正确角色回传结果,才是容易漏掉的部分。
性能结论需要拆开看。作者怀疑那 1 毫秒左右的周转时间耗在读取时区 zip,于是改成直接读取 /usr/share/zoneinfo 下的 TZif 文件,结果相同。这支持「换时区读取方式没有改善」;至于剩余差距全部来自网络轮询器唤醒和 goroutine 切换,文中没有展示足够的剖析数据,仍是作者的解释。
「标准库 HTTPS 客户端很难低于 5 MB」也受系列规则约束:标准库有 HTTP 就必须用。它不能直接推导成所有 Go 网络程序的体积下限。作者提到还有两个实现更快,但没有在本文给出完整对照表。
另外,文中的 golang:1 没有固定到实测的 go1.27.2。照抄 Dockerfile,不能保证得到相同版本。不到 5 毫秒衡量的也是客户端开销,不能当成真实 Claude 请求的总响应时间;复现命令注明不需要 key,更不能据此判断公网模型调用速度。
能拿来做什么
- 自己写前端配套的 Node 或 Go AI 客户端时,可以照这个状态流程检查工具调用:参数是否拼完整、助手消息是否保留、工具结果是否带上对应的
tool_use_id。 - 做流式读取时,把单行大小限制作为明确配置。本文的 1 MB 是具体实现选择,接入更大工具参数时,还要检查超限和扫描失败如何向上返回。
- 将 Go 工具打进 scratch 镜像前,检查 CA 证书和时区来源,并固定构建版本,避免本机运行正常、容器启动后才缺依赖。
- 想复现作者测试,可以依次运行
git clone https://github.com/AndyCross/claude-hot-path、cd claude-hot-path、./bench/bench.sh go。需要 Docker 和 Python 3,不需要 key;比较结果时,把客户端耗时与模型等待时间分开记录。