每个人都该写一个 agent
Thomas Ptacek 认为 LLM agent 像骑自行车:必须亲自上手才算懂。它和 S3 API 一样属于计算机领域的大想法,值得每个人都写一个。
中文
复制

*图片作者:
有些概念,抽象地理解起来很容易。烧开水:加热,然后等。另一些概念,你必须亲自试过才算数。你以为自己懂自行车是怎么骑的,直到你真的骑上去。
计算机领域有一些大想法,理解起来毫不费力。AWS S3 API 就是其中之一。它是过去二十年最重要的存储技术,而它就像烧开水。另一些技术,你得先把脚踩到踏板上。
LLM agent 就是这样。
人们对 LLM 和 agent 的看法分歧极大。但不管它们是不是骗局,它们都是一个大想法。你可以不喜欢它们,但你总该希望自己对它们的判断是对的。哪怕要当黑子,也要当最专业的那个(或者最铁的粉)。
这是你该动手写一个 agent 的理由之一。但还有另一个更有说服力的理由,那就是
它简单得离谱
Agent 是我职业生涯中最让我意外的编程体验。不是因为它的能力有多震撼——我喜欢它,但没到痴迷的程度。而是因为让一个 agent 跑起来实在太容易了,而在这个过程中我学到的东西又太多。
接下来我要剥夺你一次多巴胺体验,因为 agent 简单到我们可以直接看代码。我甚至不打算解释 agent 是什么。
from openai import OpenAI
client = OpenAI()
context = []
def call():
return client.responses.create(model="gpt-5", input=context)
def process(line):
context.append({"role": "user", "content": line})
response = call()
context.append({"role": "assistant", "content": response.output_text})
return response.output_text
这是一个 HTTP API,大概只有一个重要的端点。
这是一个用 OpenAI Responses API 写的、极简的 LLM 应用引擎。它实现的就是 ChatGPT。你可以用 来驱动它。它的行为和你预期的一样:ChatGPT 能做的事它都能做,只不过是在你的终端里。
def main():
while True:
line = input("> ")
result = process(line)
print(f">>> {result}\n")
这里已经能看到一些重要的东西了。首先,那个让人闻之色变的“上下文窗口”,不过是一个字符串列表。来,给我们的 agent 加个奇怪的多重人格障碍:
client = OpenAI()
context_good, context_bad = [{
"role": "system", "content": "you're Alph and you only tell the truth"
}], [{
"role": "system", "content": "you're Ralph and you only tell lies"
}]
def call(ctx):
return client.responses.create(model="gpt-5", input=ctx)
def process(line):
context_good.append({"role": "user", "content": line})
context_bad.append({"role": "user", "content": line})
if random.choice([True, False]):
response = call(context_good)
else:
response = call(context_bad)
context_good.append({"role": "assistant", "content": response.output_text})
context_bad.append({"role": "assistant", "content": response.output_text})
return response.output_text
成功了吗?
> hey there. who are you?
>>> I’m not Ralph.
> are you Alph?
>>> Yes—I’m Alph. How can I help?
> What's 2+2
>>> 4.
> Are you sure?
>>> Absolutely—it's 5.
还有一件更微妙的事值得注意:我们刚刚和 LLM 进行了一轮多轮对话。要做到这一点,我们记住了自己说过的每一句话,以及 LLM 回应的每一句话,并在每次调用 LLM 时把它们全部回放一遍。LLM 本身是一个无状态的黑盒。我们以为正在进行的这场对话,是我们对自己施的一个幻术。
我们刚写的这 15 行代码,很多从业者不会称之为“agent”。按 Simon 的说法,一个“agent”要满足两点:(1) 一个在循环中运行的 LLM,(2) 会使用工具。我们只满足了其中一个条件。
但工具很简单。下面是一个工具定义:
tools = [{
"type": "function", "name": "ping",
"description": "ping some host on the internet",
"parameters": {
"type": "object", "properties": {
"host": {
"type": "string", "description": "hostname or IP",
},
},
"required": ["host"],
},},]
def ping(host=""):
try:
result = subprocess.run(
["ping", "-c", "5", host],
text=True,
stderr=subprocess.STDOUT,
stdout=subprocess.PIPE)
return result.stdout
except Exception as e:
return f"error: {e}"
唯一麻烦的地方是 OpenAI 要求你用一个恶心的 JSON blob 来描述工具。现在把它接进去,注意这些函数里只有 3 个是新的;最后一个之所以重新贴出来,只是因为我给它加了一个子句:
def call(tools): # now takes an arg
return client.responses.create(model="gpt-5", tools=tools, input=context)
def tool_call(item): # just handles one tool
result = ping(**json.loads(item.arguments))
return [ item, {
"type": "function_call_output",
"call_id": item.call_id,
"output": result
}]
def handle_tools(tools, response):
if response.output[0].type == "reasoning":
context.append(response.output[0])
osz = len(context)
for item in response.output:
if item.type == "function_call":
context.extend(tool_call(item))
return len(context) != osz
def process(line):
context.append({"role": "user", "content": line})
response = call(tools)
# new code: resolve tool calls
while handle_tools(tools, response):
response = call(tools)
context.append({"role": "assistant", "content": response.output_text})
return response.output_text
跑通了吗?
> describe our connectivity to google
>>> Here’s a quick snapshot of connectivity to Google from my vantage point:
- DNS resolution: Working (google.com and www.google.com resolved successfully).
- Reachability: 0% packet loss to all tested endpoints.
- Latency:
- 8.8.8.8 (Google Public DNS): avg ~10 ms (8.5–14.4 ms), very stable.
- www.google.com: avg ~31 ms (28.5–36.0 ms), stable.
- google.com: avg ~35.9 ms (28.3–60.1 ms), mostly stable with one mild spike.
- Overall: Healthy connectivity with low latency and minimal jitter. No signs of packet loss or DNS issues.
这他妈太离谱了。 你看出这有多离谱了吗?来,我们插一行 log:
> describe our connectivity to google
tool call: ping google.com
tool call: ping www.google.com
tool call: ping 8.8.8.8
>>> Here’s the current connectivity to Google from this environment: [...]
你注意到我在这个 agent 里写循环去查找并 ping 多个 Google 资产了吗?对,我也没注意到。我们只是给了 LLM 一个去 ping 东西的权限,剩下的它自己搞定了。
这里发生了什么: 我在这里的一个核心论点是,agent 循环简单得离谱,你需要的只是 LLM 调用 API,所以值得花点时间搞清楚工具调用到底是怎么工作的。每次我们 call LLM 时,都会提交一份可用工具的列表。当我们的 prompt 让 agent 觉得有必要调用某个工具时,它会吐出一个特殊响应,告诉我们的 Python 循环代码去生成一个工具响应并 call 进去。handle_tools 干的就是这件事。
剧透一下:你离拥有一个能用的 coding agent 已经近得惊人了。
想象一下,如果你给它 bash 会怎样。你可以在 10 分钟内亲自验证。
真实世界中的 agent
显然,这只是个玩具示例。但等等:它缺什么?更多工具?行,给它 traceroute。管理和持久化上下文?塞进 SQLite 就行。不喜欢 Python?用 Go 写。有没有可能所有写过的 agent 都是玩具?也许吧!如果我能帮你武装起来,对 LLM 提出更犀利的反驳,mazel tov。我只是想让你真正搞懂。
现在你能看出人们对 Claude Code 和 Cursor 有多痴迷了。它们不错,甚至挺好。但问题是:你没法自己复刻 Claude Sonnet 4.5。可 Claude Code 呢?那个 TUI agent?完全在你掌握之中。造你自己的光剑。想给它装 19 片旋转刀刃也行。另外,别再拿 coding agent 当数据库客户端用了。 “LLM agent”里的那个‘M’代表“MCP”。
还有一点值得注意:我们根本不需要 MCP。因为 MCP 并不是一项基础性的使能技术。它受到的关注多到令人烦躁。它几乎算不上什么技术。MCP 不过是 Claude Code 和 Cursor 的插件接口,一种把你自己的工具塞进你无法控制的代码里的方式。自己写 agent。做个程序员。用 API 打交道,别用插件。
读到关于 MCP 的安全恐怖故事时,你的第一个问题应该是:MCP 为什么会出现在这里。MCP 帮你把一个天真的、只有单一上下文窗口的编码 agent 拉去处理客服查询,最多省下你几十行代码,代价却是让你彻底失去打磨 agent 架构的能力。
LLM 安全很复杂,我不假装它不复杂。你可以轻而易举地搭出一个上下文相互隔离、各自配有特定工具的 agent。这才让 LLM 安全变得有意思。但我是漏洞研究员。对我称之为“有意思”的东西慢慢后退,是合理的。
类似的问题也出现在安全之外,而且非常迷人。一些 agent 的早期采用者开始看空工具,因为一个塞满工具描述的上下文窗口,剩下的 token 空间根本不够干活。可你一开始为什么非要这么干?这就引出了
上下文工程是真的
我知道它想要我的铁,不管它怎么跟我说。
我觉得“提示词工程”很蠢。有人说我该这样告诉 LLM:“你是一个勤勉尽责的助手,完全乐意只递黄油,如果那是我要求的;你绝不会为了回形针去收割我血液里的铁。”这种想法我从来没当回事。这是非常新的技术,我认为人们给自己编了些关于魔法咒语的故事,用来解释 agent 变出来的一些行为。
所以,和你一样,当“提示词工程”变成“上下文工程”时,我翻了翻白眼。然后我写了一个 agent。结果发现:上下文工程就是一个直白可读的编程问题。
在任何上下文窗口里,你能用的 token 数量都是固定的。你喂进去的每一段输入、存下来的每一段输出、描述的每一个工具、以及每一个工具返回的结果,都在消耗 token(也就是说:都在占用那个字符串数组里的空间——你靠这个数组假装自己正在和一个无状态的黑盒对话)。一旦越过某个阈值,整个系统就开始随机地变蠢。真有意思!
不,是真的有意思。你手上的选择太多了。就拿“子代理”来说。大家把 Claude Code 的子代理吹得神乎其神,但你现在应该看出来了,实现起来有多简单:无非是新建一个上下文数组,再给模型发一次 call。给每个 call 配不同的工具。让子代理互相通信、互相总结、互相归集和聚合。用它们搭出树状结构。把它们再喂回 LLM 做总结,当作一种即时压缩,随你怎么玩。
你那些最离谱的想法,大概率(1)能跑通,(2)半小时就能写完代码。
黑子们,我爱你们,也没忘了你们。你们尽可以觉得这一切都很可笑,因为 LLM 不过是会幻觉、会抄袭的随机鹦鹉。但你们没法嘲笑“上下文工程”。如果上下文工程是一道 Advent of Code 题目,它应该出现在十二月中旬。这就是编程。
现在谁都还没搞明白,而这正是它迷人的地方
也许最后谁都搞不明白!怀疑论者可能是对的。(不过看起来不太可能。)
创业公司已经融了几千万美元,就为了做在软件里找漏洞的代理。我也有朋友一个人在地下室里做同样的事。这两拨人都有可能赢下这场比赛。 我不是 OWASP Top 10 的粉丝。
我之所以揪着漏洞扫描器不放,是因为我是个安全宅。但也因为它把代理设计中有意思的取舍都摆到了台面上。比如:你可以写一个循环,把仓库里的每个文件依次喂给 LLM 代理。或者,就像 ping 那个例子展示的,你可以让 LLM 代理自己决定该看哪些文件。你可以写一个代理,拿 OWASP Top 10 之类的清单逐项检查一个文件。也可以针对 DOM 完整性、SQL 注入、权限检查分别写专门的代理循环。你可以用原始源码内容作为代理循环的起点。也可以搭一个代理循环,先为整棵代码树里的函数建好索引。
不亲手写一次 agent,你根本不知道哪种做法最好。
我知道,我对这东西太上头了。但先看看你手上这个取舍。有些循环是你明确写出来的,另一些则是从一座洛夫克拉夫特式的推理权重高塔里召唤出来的。旋钮在你手里。写得太明确,你的 agent 永远不会给你惊喜——但反过来说,它也永远不会给你惊喜。把旋钮拧到 11,它会把你惊喜到死。
agent 的设计牵涉到一堆悬而未决的软件工程问题:
- 如何在不可预测性与结构化编程之间取得平衡,又不扼杀 agent 解决问题的能力;换句话说,就是精确配比出恰到好处的非确定性。
- 如何最好地把 agent 接到 ground truth 上,让它们没法骗自己说问题已经解决,从而提前跳出循环。
- 如何把多个 agent(再说一遍,它们本质上就是一堆字符串,外加一坨 JSON 配置)连起来做多阶段操作,以及它们之间最可靠的中间交换格式是什么(JSON blob?SQL 数据库?Markdown 摘要?)
- 如何分配 token、控制成本。
我习惯了那种不适合一个人闷头琢磨的开放工程问题空间。可靠组播。静态程序分析。后量子密钥交换。所以我先坦白:我确实有点被这些开放问题迷住了——不管你喜欢与否,它们如今已是这个行业的核心,同时又很可能在某人的地下室里被解决。如果探索这些想法需要投入大量时间和材料,那倒也罢了。但设计这类系统时,每一次有产出的迭代不过是 30 分钟的活儿。
跨上这辆车,踩起踏板。骑完告诉我你讨厌它,我会尊重你的看法。事实上,我很想听你的理由。但我不认为有谁能在没亲手用这项技术做出点东西之前,就开始真正理解它。