区块链 区块链技术 比特币公众号手机端

我用 Go、Python 和 TypeScript 跑了同一个 AI Agent,看看各自都在哪里出了问题

liumuhui 4小时前 阅读数 1 #区块链

Python 版本在 120 个并发任务时倒下了,因为某个工具里的一次同步 HTTP 调用阻塞了整个事件循环。

TypeScript 版本顺利编译通过,然后在生产环境中崩溃了,因为模型返回了一个工具参数,而我精心定义的类型接口断言这种参数不可能存在。

Go 版本从未崩溃。但它花了十一个小时才写完,因为其他人作为库导入的 agent loop 在 Go 中并不存在,我不得不自己构建。

同一个 agent。同一个模型。同样的 prompts。三种截然不同的痛苦。

大多数语言推荐来自只构建过一次的人。我构建了三次,对每个实现各跑了 500 个相同的任务,并记录下了哪里出了问题。

我构建了什么,又是如何测量的?

一个用于 Web3 仪表盘的合约分诊 agent。它接收一个合约地址,依次调用四个工具,并返回前端渲染的风险摘要。这些工具分别获取已验证的源码、拉取近期转账量、检查 allowlist、并对结果打分。我在同一台 4 vCPU 机器上,用相同的模型、相同的 temperature、相同的 prompts,对每个实现各运行了 500 个相同的任务。

四个工具,每个任务大约六轮模型调用,一个结构化的 JSON 输出。刻意做得平淡无奇,这也正是大多数生产级 agent 在演示结束之后所呈现的形态。你的数字会有所不同。但失败模式是相通的。

Python 里是什么出了问题?

并发。Python 写起来最快,也最先倒下。一个工具在 async agent loop 内部发起了同步 HTTP 调用,这阻塞了进程中所有其他任务的事件循环。吞吐量在大约 120 个并发任务时趋于停滞,p95 延迟变成了原来的三倍。一旦你看出来,修复方法很简单,但代码里没有任何地方看起来有问题。

下面就是让我耗费了一下午的那行代码:

async def fetch_source(address: str) -> str:
    # requests 是同步的。在 async 循环中它会阻塞
    # 整个事件循环,而不仅仅是这个协程。
    resp = requests.get(f"{EXPLORER}/api?address={address}")
    return resp.text

它能跑。测试通过。单个用户没问题。但在规模化场景下,当这个调用还在进行时,进程中的其他所有 agent 都会停住。

修复方法:

async def fetch_source(address: str) -> str:
    async with httpx.AsyncClient(timeout=10) as client:
        resp = await client.get(f"{EXPLORER}/api?address={address}")
        return resp.text

## 对于没有 async 版本的库,把它们移出事件循环
async def score(payload: dict) -> float:
    return await asyncio.to_thread(cpu_heavy_scoring, payload)

更深层的问题在于 Python 的生态一半是 async 的,一半不是。你的 agent 框架是 async 的。你的 web3 客户端或 PDF 解析器可能不是。任何一个工具里的同步依赖都会毒害整个进程,症状是神秘的延迟而不是报错。

Python 在开发速度上仍然胜出。两个小时就有了一个能跑的 agent,180 行代码。其他语言都望尘莫及。

TypeScript 里是什么出了问题?

类型安全,恰恰是在我最需要它的边界上失效了。TypeScript 的类型在运行时会被擦除,而模型的工具参数是以不受信任的 JSON 形式到达的。我的接口描述的是我希望会到来的东西,而不是实际到来的东西。当模型在类型声明为 number 的地方返回了字符串时,编译器无话可说,agent 在三次工具调用之后崩溃了。

这几乎是每一篇 TypeScript agent 教程都会给出的模式:

interface RiskArgs {
  address: string;
  minVolume: number;
}
// 这个类型断言是编译器无法兑现的承诺
const args = toolUse.input as RiskArgs;
if (args.minVolume > 1000) { /* 当它是 "1000" 时就会爆炸 */ }

这个断言是个谎言。toolUse.input 是语言模型生成的 JSON。在最关键的意义上,它就是 unknown

在边界处做校验,问题就消失了:

import { z } from "zod";

const RiskArgs = z.object({
  address: z.string().regex(/^0x[a-fA-F0-9]{40}$/),
  minVolume: z.coerce.number().nonnegative(),
});
const parsed = RiskArgs.safeParse(toolUse.input);
if (!parsed.success) {
  // 把错误反馈给模型,而不是直接崩溃
  return toolResult(toolUse.id, `Invalid arguments: ${parsed.error.message}`, true);
}

把校验错误返回给模型而不是抛出异常,这是人们最容易忽略的部分。模型读到它之后通常会在下一轮自我纠正。agent 得以恢复而不是死掉。

TypeScript 的另一项代价是版本滞后。Anthropic 和 OpenAI 的 TypeScript SDK 都在积极维护,但一些 beta 功能会先在 Python 中落地,几周后才跟进到 TypeScript。

Go 里是什么出了问题?

运行时什么都没坏。坏掉的是我的进度表。Anthropic 的 Claude Agent SDK 和 OpenAI 的 Agents SDK 都只提供 Python 和 TypeScript 版本,没有 Go 实现。Go 有官方支持的 API 客户端,所以调用模型没问题。但 agent loop、工具分发、重试和上下文管理都得你自己写。十一个小时,540 行代码。

记住我,以便更快登录

准确地说,因为这一点经常被误传:官方的 anthropic-sdk-go 客户端是真实存在、有类型且持续维护的。Messages、streaming、tool calling 和 MCP 都有。缺的是替你跑循环的那个 harness。所以你得自己写:

for turn := 0; turn < maxTurns; turn++ {
	msg, err := client.Messages.New(ctx, params)
	if err != nil {
		return nil, fmt.Errorf("turn %d: %w", turn, err)
	}
params.Messages = append(params.Messages, msg.ToParam())
 if msg.StopReason != anthropic.StopReasonToolUse {
  return msg, nil
 }
 var results []anthropic.ContentBlockParamUnion
 for _, block := range msg.Content {
  tu, ok := block.AsAny().(anthropic.ToolUseBlock)
  if !ok {
   continue
  }
  out, err := registry.Dispatch(ctx, tu.Name, tu.Input)
  results = append(results,
   anthropic.NewToolResultBlock(tu.ID, out, err != nil))
 }
 params.Messages = append(params.Messages,
  anthropic.NewUserMessage(results...))
}

这就是全部思路,而且并不难。难的是后面还有五十行类似的代码,外加重试、超时、token 记账,以及 Python SDK 已经处理好的每一个边界情况。

回报会在之后到来。工具调用在 goroutine 中运行,没有可被饿死的事件循环,内存占用只有其他方案的三分之一,部署就是一个静态二进制文件。对长期运行的服务来说值得。对原型来说不值得。

基准测试到底显示了什么?

三者的单任务延迟几乎完全相同,因为模型推理主宰了一切。差异出现在并发和资源占用上。Go 以三分之一的内存支撑了大约十六倍于 Python 的并发任务数。Python 用两小时就得到了能跑的 agent,而 Go 花了十一小时。没有人能在所有维度上都获胜。

在一台 4 vCPU 实例上的 500 个任务中:

→ 任务耗时中位数:Python 8.4s,TypeScript 8.1s,Go 7.9s

→ p95 任务耗时:Python 22.1s,TypeScript 19.7s,Go 14.2s

→ 延迟恶化前的并发任务数:Python 120,TypeScript 300,Go 2,000

→ 100 并发时的常驻内存:Python 480 MB,TypeScript 610 MB,Go 210 MB

→ 代码行数:Python 180,TypeScript 260,Go 540

→ 首个可用版本的开发时间:Python 2h,TypeScript 4h,Go 11h

仔细看第一行。中位延迟只有百分之三的差距,意味着语言选择并不会让单个用户感觉 agent 更快。它决定的是每台机器你能服务多少用户,以及你在过载时退化得有多优雅。这是一个成本问题,而不是速度问题,而大多数比较把它搞反了。

安全性上有哪些权衡?

每种语言都在同一条边界上以不同的方式失败:模型生成的工具参数是不受信任的输入。Python 的动态类型意味着一个畸形的参数会在任何东西发出警告之前就抵达你的数据库驱动。TypeScript 的类型在运行时消失殆尽,给人虚假的信心。Go 强制显式反序列化,能尽早抓住结构错误,却抓不住语义错误。三者都需要你自己编写校验。

在三个实现中都成立的规则:

→ 在工具参数接触真实系统之前,先用 schema 校验每一个参数

→ 永远不要通过拼接模型输出来构建 shell 命令、SQL 查询或文件路径

→ 给每个工具设置自己的超时和自己的最小权限凭证,而不是共用 agent 的

→ 把校验失败作为工具错误返回给模型,让循环得以恢复

→ 对每个会话限制轮数和花费,因为陷入死循环的 agent 就是一起账单事故

→ 让 API 密钥远离模型可以通过 bash 或 eval 工具触及的任何进程

最后一条在 Python 和 TypeScript 中杀伤力最大,在那里添加一个代码执行工具只需两行代码,而环境变量被倾倒出去只差一次 prompt injection。

你应该选哪一个?

没有赢家,而那些宣称有赢家的文章都是在推销什么东西。

→ 原型开发、研究,或者任何由 agent framework 承担主要工作量的场景:Python → 生活在现有 Web 产品内部、与前端共享类型并随之一起发布的 agent:TypeScript → 长期运行、高并发的 agent 服务,资源占用和可预测性比迭代速度更重要:Go

我自己的分配方式:用 Python 做原型,用 TypeScript 发布面向用户的 agent,只有在并发逼不得已时才迁移到 Go。在四个项目里,最后这一步只发生过一次。

真正改变我想法的一点是:语言本身从来没有引发过 bug。每一次失败都来自同一个地方:概率系统的不可信输出闯入了确定性代码。Python 让它悄无声息地进来了。TypeScript 让它信心满满地进来了。Go 则让我亲手写了那扇门。

选择一种你能忍受的失败模式。

如果你也曾把同一个 agent 构建过两次,请把你那里坏掉的地方贴出来。我最好奇的是尝试过 Rust 的人,以及在没有切换到多进程的情况下让 Python 扛过 500 个并发任务的人。

  • 原文链接: medium.com/@ancilartech/...
  • 鸿途知科网 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
版权声明

本文仅代表作者观点,不代表区块链技术网立场。
本文系作者授权本站发表,未经许可,不得转载。

发表评论:

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

热门