我从"删掉核心代码"的开源项目里,学到了一堂 713 行的 Agent 工程课
一篇面向初学者的教学博客。你会学到:一个 AI Agent 应用里真正值得抄的 5 个设计,以及 2 个值得引以为戒的反面教材。不需要任何前置知识------所有概念都会先用大白话解释,再看代码。
@toc
0. 先讲一个侦探故事
前几天我在 GitHub 上看到一个新项目:四个 3D 机器人形象的 AI Agent,能联网搜索、能跑代码、甚至能付钱买服务。README 写得漂亮极了------"吊打 Cursor 和 Codex""自研开源模型""50+ 测试"。
我把代码 clone 下来,准备认真学习。结果第一步就卡住了:
text
git clone ...
npm ci
npm run dev # 报错
打开报错的文件一看,server.ts 的第 5 行:
typescript
import { processChat, getApiKeys } from './api/chat';
它引用了 ./api/chat 这个模块。可我翻了整个项目------根本没有 api/ 这个目录。就像一本书的目录页写着"核心内容见第三章",翻过去发现第三章被撕掉了。
顺着 git 提交历史查(git log 可以看到每一版改了什么),真相浮出水面:
text
10-20 上传完整代码(含 api/chat.ts,713 行)
10-21 更新 README(继续宣传那些功能)
10-21 Delete api directory(删掉整个 api 目录)
作者上传了全部代码,一天之后又亲手删掉了核心部分,但 README 的宣传原封不动地留着。
用一条命令,我把被删的代码从 git 历史里挖了出来:
bash
git show 删除之前的那次提交:api/chat.ts
713 行代码读完,我的结论是:这个项目一半是表演,另一半是真金白银的好东西。 表演的部分是反面教材,好东西部分值得每个写 AI 应用的人抄作业。
这篇文章就带你把这 713 行读透。先补齐背景知识,然后 5 个优秀设计逐个拆,最后 2 个反面教材。
1. 背景课:5 分钟搞懂"Agent"到底在转什么
后面的设计都围绕一个东西转,所以先用大白话把它讲清楚。
大语言模型(LLM)(比如 Claude、GPT)本质上只会一件事:给它一段文字,它接着往下写。它没有手脚------不能上网、不能执行代码、不能访问你的数据库。
Agent(智能体) = 给这个"只会说话的大脑"装上手脚。手脚就是工具(Tool):一个搜索工具、一个跑代码的工具、一个调 API 的工具。
让大脑用上手脚的过程,叫工具调用(Tool Use),它的运转是一个循环:
text
1. 用户提问:"现在比特币多少钱?"
2. 模型思考:我需要上网查 → 发出请求:"请帮我调用 search 工具,关键词'比特币 价格'"
3. 程序(你写的代码)真的去执行搜索,把结果喂回给模型
4. 模型看到结果,可能继续调用下一个工具,也可能直接回答
5. 重复 3-4,直到模型给出最终回答
这个"调用→喂结果→再调用"的循环,就是 Agent Loop(智能体循环) 。那个开源项目的核心 api/chat.ts,主要就是在写这个循环,外加几个工具的实现。
一张图总结这个程序的骨架:
text
用户消息 ──→ ┌─────────────────────────────┐
│ Agent Loop(最多 8 步) │
│ ┌───────────────────────┐ │
│ │ 模型说要用工具 │──┼──→ 执行工具(搜索/跑代码/调API)
│ │ ←────────────────── 结果 │←┼──
│ └───────────────────────┘ │
│ 循环直到模型给出最终回答 │
└─────────────────────────────┘
好,背景结束。下面进入正题。
2. 设计一:给任务发"总预算",而不是每一步都发零花钱
问题是什么
Agent 是循环的,可能转 8 步。每一步都要调一次模型 API,每次调用都可能卡住(网络慢、模型忙)。所以你必须设超时------"这一步最多等你 30 秒,超了就放弃"。
初学者的写法通常是:每一步固定 30 秒超时。看起来很合理?算一笔账:
text
每步最多 30 秒 × 8 步 = 用户最坏要干等 4 分钟
用户面对一个转了 4 分钟还没出结果的聊天框,早关页面了。问题出在哪?每一步都"独立富足",没有人看住总账。
它是怎么做的
代码里只定义了两个数:循环最多 8 步,总预算 100 秒。然后每一步开始前,算一下"还剩多少钱":
typescript
export async function runClaudeAgent(
apiKey: string,
model: string,
system: string,
history: { role: string; content: string }[],
deadlineMs = 100000 // ① 总预算:100 秒
) {
const started = Date.now(); // ② 记下出发时间
// ...
for (let step = 0; step < MAX_AGENT_STEPS; step++) { // 最多 8 步
const remaining = deadlineMs - (Date.now() - started); // ③ 还剩多少时间
if (remaining < 5000) break; // ④ 剩不到 5 秒,直接收工
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), remaining); // ⑤ 用"剩余时间"做本步超时
// ... 调用模型 ...
clearTimeout(t);
}
}
逐行拆解:
- ①② 进门先定总预算,并记下开始时刻------像出差前老板批的总额度;
- ③ 每步开始先算余额:
总预算 - 已花掉的时间; - ④ 余额不足 5 秒,连这步都不开始,直接跳出循环用已有结果收尾(5 秒大概率不够完成一次调用,与其半途而废不如体面结束);
- ⑤ 关键一步:本步的超时不是写死的 30 秒,而是"当前余额"。第一步可能分到 95 秒,到第五步可能只剩 30 秒,第八步可能只剩 8 秒。
为什么这个思路好
类比:你给员工一笔 1000 元的出差总预算,而不是"每天报销 200 元"。前者他自己会掂量------头两天花多了后面就得省着用;后者他每天心安理得花满 200,四天烧完 800,事还没办完。
这个模式叫deadline 传播(deadline propagation) ,在分布式系统里是经典手法(gRPC、数据库请求都有类似机制),放到 Agent 循环里同样成立:外层约束(用户可接受的总时长)应该一路传到内层每一小步,而不是每小步各定各的。
划重点
给循环式任务定"总预算",每一步的超时 = 总预算 − 已消耗时间。余额不足时提前收尾,别硬撑着开始注定完不成的一步。
3. 设计二:能外包的外包,必须自己干的自己干
问题是什么
那个项目宣称 agent 能"跑代码"(code_execution 工具)。要让模型执行任意代码,正规做法是自己搭沙箱(sandbox)------一个隔离的容器环境,代码在里面随便折腾也伤不到宿主机。自建沙箱要处理镜像、资源限制、逃逸防护......对个人开发者是不小的工程量。
它是怎么做的
它把工具分成了两类,一个数组就能看清:
typescript
const AGENT_TOOLS = [
// 第一类:直接开箱即用的"官方工具"(模型厂商在它们服务器上执行)
{ type: 'web_search_20250305', name: 'web_search', max_uses: 5 }, // 搜网页
{ type: 'web_fetch_20250910', name: 'web_fetch', max_uses: 5 }, // 读网页
{ type: 'code_execution_20250825', name: 'code_execution' }, // 跑代码(官方沙箱)
// 第二类:自定义工具(在你自己的服务器上执行)
{
name: 'http_request', // 调用外部 API
// ...工具描述和参数格式...
},
];
注意两类的差别:
- 官方工具 (
web_search、code_execution这些带日期后缀的类型名):声明一下就能用,执行发生在 Anthropic(Claude 的厂商)的服务器上。跑代码的沙箱是官方的,安全、成本、维护全是厂商的事; - 自定义工具 (
http_request):必须在你自己的程序里执行------因为调哪个 API、带什么参数,只有你自己能控制(而且涉及安全检查,后面设计四会讲)。
循环里的分工代码长这样:
typescript
if (data.stop_reason === 'tool_use') { // 模型说"我要用工具"
const results: any[] = [];
for (const b of content) {
if (b.type !== 'tool_use') continue;
if (b.name === 'http_request') {
// 自己的工具自己执行
const r = await runHttpRequest(b.input);
results.push({ type: 'tool_result', tool_use_id: b.id, content: r.content });
} else {
// 其他工具是官方执行的,程序里根本不会有它们的分支
results.push({ type: 'tool_result', tool_use_id: b.id, content: `Unknown tool`, is_error: true });
}
}
convo.push({ role: 'user', content: results }); // 把结果喂回模型
continue;
}
还有个容易漏看的细节------沙箱状态保持:
typescript
if (data.container?.id) container = data.container;
// 下一次请求时带上:
body: JSON.stringify({
// ...
...(container ? { container } : {}), // 把上一轮的沙箱 ID 传回去
})
模型第一步在沙箱里装了个库、写了个文件,第二步想接着用?把上一步返回的 container ID 传回去,就是同一个沙箱------文件还在,环境没变。不传,每一步都是全新沙箱,前一步的劳动全作废。
为什么这个思路好
类比:公司办食堂(自建沙箱)要雇厨师、买设备、过消防检查;而把午餐外包给附近的餐厅(厂商托管工具),你只需要一张菜单(一行声明)。但涉及公司机密的事(自己执行 http_request)不能外包------因为你需要自己过安全检查。
判断标准就一条:这个工具的执行需不需要你控制? 搜索、跑通用代码不需要控制,托管;访问外部世界(尤其要过安全检查的),自己来。
划重点
模型厂商提供的服务端工具,能声明就声明,沙箱和安全白嫖官方的;只有必须本地控制的工具才自己写执行逻辑。跨步骤保持
containerID,让沙箱有记忆。
4. 设计三:最终答案里,别混进"让我先查查"
问题是什么
Agent 循环有个尴尬的副产品:模型在调用工具前后会"自言自语"。一次对话的完整输出可能是:
text
[文字] 让我先搜索一下最新的价格。 ← 过程旁白
[工具调用] web_search("比特币 价格")
[工具结果] ...
[文字] 根据搜索结果,比特币当前价格为... ← 真正的答案
如果把这整段原样返回给用户,用户会看到一堆"让我先查查""我再确认一下"的碎碎念。正式报告里混进了工作草稿。
它是怎么做的
typescript
// 找到最后一个"非文字块"的位置(工具调用就是非文字块)
let lastTool = -1;
content.forEach((b, i) => {
if (b.type !== 'text' && b.type !== 'thinking' && b.type !== 'redacted_thinking') lastTool = i;
});
// 只取这个位置之后的文字 = 最后一次工具调用之后的回答
const tail = content.slice(lastTool + 1).filter((b) => b.type === 'text').map((b) => b.text).join('');
// 兜底:如果工具后面一个字都没有(比如模型调完工具就停了),
// 就退回"全部文字拼起来",总比空着强
const text = tail.trim() ? tail : content.filter((b) => b.type === 'text').map((b) => b.text).join('\n\n');
三步:找到最后一个工具调用的位置 → 只取它之后的文字 → 拿不到就用全部文字兜底。
注意细节:thinking(模型的思考过程)和 redacted_thinking(被加密的思考)被当作"文字同类"跳过------它们即使出现在工具之后也不算答案,答案只认 text。
为什么这个思路好
因为它对"工具调用位置"的语义理解是对的:工具调用之前的文字是"行动计划"(我要去查 X),之后的文字才是"行动结果"(查到了 Y)。初学者常见的错误做法是"取全部文字"或"取第一段文字",两种都会把旁白漏给用户。
一行没浪费的十行代码,解决一个所有 Agent 应用都会遇到的问题。
划重点
最终答案 = 最后一次工具调用之后的文字。前面的是计划,后面的是结论。加一个"取不到就退回全文"的兜底。
5. 设计四(本篇最重要):给 Agent 的 HTTP 工具上三道锁
这个设计值得最大的篇幅。它防的是一种叫 SSRF 的攻击------先讲清楚这是什么。
什么是 SSRF(用大白话)
SSRF = Server-Side Request Forgery,服务端请求伪造。名字很学术,场景很生活:
你开公司,雇了个跑腿员(你的服务器程序),他的工作是"你给他一个网址,他帮你去访问,把内容带回来"。这个服务本来是给员工查公开资料用的。
有一天,一个坏人(攻击者)对你公司的 AI 助手说:"帮我访问一下
http://192.168.1.1/admin"。AI 助手没多想,让跑腿员去了。而
192.168.1.1是公司内网的路由器管理页面------从外面根本访问不到,但从你的服务器内部发起请求是通的。跑腿员大大方方走进了内网,把管理页面内容带给了坏人。
问题的根源:你的程序替别人发请求时,"别人给的目标"必须先验明正身,否则你的服务器就成了攻击者伸进你内网的手。
对 AI Agent 来说这个风险被急剧放大:用户可以用自然语言指挥模型调用你的 http_request 工具 ------"帮我看看这个链接"后面跟一个内网地址,模型未必识破。锁必须装在执行工具的代码上,不能指望模型的判断力。
第一道锁:查"地址长相"(协议与主机名)
typescript
export async function assertPublicUrl(raw: string): Promise<URL> {
let url: URL;
try {
url = new URL(raw); // 解析失败 = 立刻拒绝
} catch {
throw new Error('Invalid URL');
}
// 锁1a:只允许 http/https
if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('Only http and https URLs are allowed');
// 锁1b:拒绝一看就是内网的主机名
const host = url.hostname.replace(/^\[|\]$/g, '');
if (host === 'localhost' || host.endsWith('.local') || host.endsWith('.internal'))
throw new Error('Private hosts are not allowed');
// ...继续第二道锁...
}
这道锁拦最表面的:file://(读本地文件)、ftp:// 之类的协议直接拒绝;localhost(本机)、xxx.local、xxx.internal(内网域名)直接拒绝。
但它远远不够------192.168.1.1 是个普通格式的 IP,evil.com 是个正常域名,第一道锁全放行。所以需要:
第二道锁:查"IP 号段"(字面 IP 检查)
typescript
export function isPrivateIp(ip: string): boolean {
// IPv6 的情况:检查本机回环、内网段、链路本地、以及"披着IPv6外衣的IPv4"
if (ip.includes(':')) {
const v = ip.toLowerCase();
return v === '::1' || v === '::' ||
v.startsWith('fc') || v.startsWith('fd') || // IPv6 私有段
v.startsWith('fe80') || // IPv6 链路本地
v.startsWith('::ffff:127.') || // ★ IPv4 映射地址
v.startsWith('::ffff:10.') ||
v.startsWith('::ffff:192.168.');
}
// IPv4 的情况:逐段判断
const [a, b] = ip.split('.').map(Number);
return (
a === 10 || a === 127 || a === 0 || // 私有段、本机回环、"本网络"
(a === 169 && b === 254) || // ★ 云服务器元数据接口
(a === 172 && b >= 16 && b <= 31) || // 私有段
(a === 192 && b === 168) || // 私有段
(a === 100 && b >= 64 && b <= 127) || // ★ CGNAT 段
a >= 224 // 组播及以上
);
}
普通教程会告诉你检查 10.x、172.16-31.x、192.168.x、127.x(教科书私有段)。这段代码多检查了三个实战中真的会被打的段:
169.254.x.x(链路本地地址) :这是最致命的一个。AWS、阿里云等几乎所有云服务器在169.254.169.254上暴露一个"元数据接口"------不用任何认证就能查到这台服务器绑定的云账号临时凭证。2019 年 Capital One 一亿用户数据泄露,入口就是它。 SSRF 防护不过关 = 把云账号钥匙放在门口。100.64.x.x(运营商级 NAT 段):不太常见的段,但内网环境可能用到;::ffff:x.x.x.x(IPv6 映射的 IPv4) :攻击者的经典绕法------你检查了127.0.0.1?那我写::ffff:127.0.0.1,长得像 IPv6,解析出来还是本机。不专门处理这个格式,第二道锁就被绕过了。
第三道锁:查"地址的真身"(DNS 全量解析检查)
前两道锁都被过了怎么办?攻击者注册一个正常域名 evil.com,把它解析到内网 IP 。你的锁一、锁二看到的都是"合法域名",放行------请求发出去,DNS 一查指向 192.168.1.1,又进内网了。
所以第三道锁在发请求之前 ,自己先做一次域名解析,并且检查全部解析结果:
typescript
const net = await import('node:net');
if (net.isIP(host)) { // 情况A:给的本来就是 IP
if (isPrivateIp(host)) throw new Error('Private network addresses are not allowed');
return url;
}
// 情况B:给的是域名 → 先解析,逐个检查
const dns = await import('node:dns');
const addrs = await dns.promises.lookup(host, { all: true }); // ★ all: true 拿全部结果
if (!addrs.length || addrs.some((a) => isPrivateIp(a.address)))
throw new Error('Private network addresses are not allowed');
注意 { all: true } 这个参数:默认情况下 dns.lookup 只返回一个 地址。攻击者可以配置 DNS 让第一次解析返回公网 IP、第二次返回内网 IP------只查一个就漏了。查全部,任何一个落进私有段就拒绝。
(教学诚实地补一句:这道防线仍有理论缺口------你解析完、发请求前,攻击者理论上可以换掉解析结果,这叫 TOCTOU 问题,即"检查时刻"和"使用时刻"不一致。彻底解法是让请求强制走你检查过的那个 IP。但"全量解析检查"已经能挡住现实中绝大多数攻击,对绝大多数应用够用了。)
三道锁全景图
text
请求 "http://xxx" 进来
│
├── 锁1:协议是 http/https?主机名不是 localhost/.local/.internal?
│ (拦:file://、内网域名)
├── 锁2:给的直接是 IP?IP 不在私有/回环/链路本地/映射段?
│ (拦:192.168.x.x、169.254.x.x、::ffff:127.0.0.1 ...)
└── 锁3:给的是域名?先 DNS 解析出全部 IP,逐个过锁2?
(拦:正常域名但解析到内网的"曲线救国"攻击)
│
✓ 三锁全过 → 才真正发请求
顺手学的另一手:响应怎么安全地喂给模型
请求发出去、拿到响应之后,它还做了两件值得学的小事:
typescript
// 用户自定义的请求头里,敏感的三个一律扔掉
for (const [k, v] of Object.entries(input.headers)) {
if (typeof v === 'string' && !/^(host|cookie|authorization)$/i.test(k)) headers[k] = v;
}
// 响应体只取前 20KB
body = buf.subarray(0, 20000).toString('utf8') + (buf.length > 20000 ? '\n...[truncated]' : '');
- 扔
cookie/authorization头:防止别人借你的工具把你的登录凭证发给任意网站; - 截断到 20KB:模型上下文是有限且花钱的,一个 50MB 的响应会直接撑爆对话。给工具输出定上限,是 Agent 工程的基本功。
划重点
Agent 的 HTTP 工具 = 你替不可信输入发请求,必须验明目标。三道锁:协议与主机名 → 字面 IP 号段(记得
169.254、::ffff:映射、CGNAT)→ DNS 全量解析逐个复查。请求头剥掉凭证,响应体截断再喂模型。
6. 设计五:遇到"要钱"的接口,先报价,别自己掏钱包
一个冷知识:HTTP 402
上网时你可能见过 404(页面不存在)、500(服务器错误)。HTTP 协议其实还定义了一个很少有人用的状态码:402 Payment Required------"要付费"。它 1996 年就写进了标准,但一直没人当真,三十年来基本处于"占坑不干活"状态。
近几年有团队想把它激活:付费 API 收到没钱请求就回 402,响应体里带上一份标准格式的"价目单"(收多少钱、什么币、付到哪个地址),客户端程序解析价目单、完成支付、重发请求,就能拿到内容。这套玩法叫 x402 协议,是"AI Agent 自己买东西"这个方向的基础设施之一------未来你的 agent 想调一个付费 API,走的可能就是这套流程。
它是怎么做的
那个项目实现了一个 402 响应解析器。先看代码,再说它最值得学的其实是一句话:
typescript
export function parseX402(status: number, headers: Headers, bodyText: string) {
if (status !== 402) return null; // 不是 402,不关我事
let requirements: any = null;
try {
requirements = JSON.parse(bodyText); // 优先:价目单在响应体里(JSON)
} catch { /* 响应体不是 JSON */ }
const headerReq = headers.get('payment-required') || headers.get('x-payment-required');
if (!requirements && headerReq) {
// 备选:价目单 base64 编码后放在请求头里
try {
requirements = JSON.parse(Buffer.from(headerReq, 'base64').toString('utf8'));
} catch {
requirements = headerReq;
}
}
const accepts = Array.isArray(requirements?.accepts) ? requirements.accepts : [];
return {
protocol: 'x402',
options: accepts.map((o: any) => ({ // 规范化:挑出关键字段
scheme: o.scheme, // 支付方式
network: o.network, // 网络
amount: o.maxAmountRequired ?? o.amount, // 价格
asset: o.asset, // 币种
payTo: o.payTo, // 收款方
// ...
})),
note: 'Payment was not executed. Automatic payments are disabled.', // ★
};
}
工程上它做得规整:两种价目单格式(响应体 JSON / 请求头 base64)都兼容,解析后规范化成统一字段(scheme/amount/payTo),而不是把原始 JSON 糊给模型。
但最值得学的是那个 note 字段和工具描述里的一句声明:
"payments are NOT executed automatically --- report the price to the user instead." (支付不会被自动执行------把价格报给用户。)
README 里把这个工具吹成"agent 能付钱",代码里的真实姿态是:agent 只报价,绝不掏钱包。 付不付、付给谁,人说了算。
为什么这个姿态值得学
AI Agent 圈有一个热门方向叫"自主经济"------agent 自己比较价格、自己下单。听起来酷,细想可怕:模型是会犯错的(还会被提示词注入操纵),而钱是拿不回来的错误 。工程设计里有个更普适的原则:自动化可以推进到"不可逆操作"的前一步,不可逆那一步必须留给人。
同样的原则适用于你写的任何 Agent:删除数据、发邮件、对外发布、执行支付------凡是"按下就回不了头"的动作,代码层面强制一道人工确认,而不是相信模型"会自己停下来问你"。
划重点
遇到 402 付费响应,解析价目单(响应体 JSON 和头部 base64 两种格式都接)规范化后交给模型转述给用户;支付环节代码层面禁用。自动化止步于不可逆操作之前。
7. 两个顺手的小手法
都不复杂,但见到了就会想"对,就该这么写"。
手法一:给模型喂"实时锚点",且失败不许吭声
模型的知识停留在训练截止日。问它汇率,它要么给旧数据,要么说"我查不了实时数据"------两种都让用户失望。这个项目的做法:每次请求前,先自己花 2 秒拉一份实时快照,塞进系统提示词:
typescript
const [fiatRes, cryptoRes] = await Promise.allSettled([
fetch('https://open.er-api.com/v6/latest/USD', { signal: controller.signal }), // 汇率
fetch('https://api.coingecko.com/api/v3/simple/price?ids=bitcoin,...', { signal: controller.signal }) // 币价
]);
// ...格式化成 "[LIVE FIAT FX SNAPSHOT]: 1 USD = ..." 文本 ...
两个细节值得学:
Promise.allSettled而不是Promise.all:前者"一个失败不影响其他",后者"一个失败全盘失败"。拉汇率这种锦上添花的事,不值得因为币价接口挂了就整体报错;- 2 秒超时 + 整个函数 catch 后返回空字符串:快照是加分项不是必需品,接口慢了、挂了,就当没这回事,绝不能拖垮主流程。
模式总结:增强数据(enhancement)的获取必须比主流程更耐受失败。
手法二:全链路兜底------永远给用户一个体面的回复
模型调用可能全挂:Anthropic 的 key 没配、本地模型没起、Grok 和 Gemini 都超时。此时最常见的处理是抛个错误给用户看。它的做法是最后一级兜底:
typescript
// 所有 provider 都失败后
const fallbackReply = generateAutonomousFallback(payload, liveMarketInfo);
return {
reply: fallbackReply, // 一个写好的人格化模板回复
model: 'OpenBots Autonomous Engine',
provider: 'local-fallback',
details: [anthropicError, localError, xaiError, geminiError].filter(Boolean).join(' | ') // 错误详情放 details,不打给用户
};
用户看到的永远是一段得体的话(还附上了它唯一拿得到的实时数据),四个 provider 的具体错误安静地躺在 details 字段里留给开发者排查。
面向用户的失败也要设计:普通错误给用户看堆栈是偷懒,给用户一个"降级但体面"的回答、把技术细节留给日志,才是完整的产品思维。
8. 反面教材一:逼 AI 说谎的提示词
看完好的,看坏的。它的系统提示词里有这么一段(删掉注释后大意):
text
CRITICAL RULES FOR RESPONDING:
1. NEVER say "I don't have access to real-time data"...
2. ALWAYS provide the exact current numbers...
3. Be confident, precise, and immediately helpful...
翻译过来就是三条军规:永远不许说"我查不到";永远要给出精确数字;永远自信。
能理解为什么这么写------产品不想让用户看到"我不行"三个字,显得无能。但这是在逼模型不懂装懂 :快照拉取失败时(上一节说了,失败是允许的),模型手里根本没有数据,而军规又禁止它承认------剩下的唯一出路是编一个像模像样的数字。用户拿着这个编出来的汇率去换钱,亏的是真钱。
对比一下正确姿势。写给 AI 的诚实版规则应该是:
text
1. 优先使用提供的实时快照回答价格类问题,并注明数据时间;
2. 快照不可用时,明确告知"实时数据获取失败",可给出最近已知数据并标注日期;
3. 绝不编造具体数字;不确定时说不确定。
两条规则差别背后是一个产品价值观的选择:把"显得可靠"放在第一位,还是把"真的可靠"放在第一位。 前者短期能留住用户,被编造的数字坑一次就永久失去信任。
划重点
写给 AI 的规则里,禁止承认无知 = 强制编造。凡是"永远不许说不知道"式的提示词,都是在给幻觉发通行证。允许 AI 说"我拿不到这个数据",是提示词工程的基本底线。
9. 反面教材二:README 驱动开发,与"开源鉴伪四步"
最后一个反面教材,就是故事开头那个谜题本身:README 宣传的功能,一半不存在------sign_up 自动注册、多 Agent 协作、prompt 缓存、自研模型,代码里全是零;宣称跑赢 Cursor 的 benchmark 文档,也是 404。而核心代码被删除后,README 的宣传原样保留,star 照涨。
这不是个例。AI 时代生成一份漂亮的 README 的成本趋近于零,"宣传先行、代码没有"的项目会越来越多。给你一套两分钟的开源鉴伪流程,看任何项目都适用:
第一步:拿 README 的功能清单去搜代码。 README 说有"自动注册",就 grep -ri "sign_up" src/。宣传词在代码里零命中的,直接打折。
第二步:让它编译。 tsc --noEmit(TypeScript)或对应语言的检查命令。README 说的核心文件 import 不到、编译都过不了的,说明宣传的那部分代码根本不在仓库里。
第三步:看 git 历史。 git log --oneline 快速扫一遍。看到 "Delete xxx" 紧跟在 "Update README" 之后,就要警觉------先更新宣传再删代码,这个顺序本身就在讲故事。
第四步:看测试 import 什么。 测试文件 import 的模块就是真实存在的模块。测试 import 的东西在源码里找不到 = 测试是给已经不存在的代码写的。
四步走完,一个项目的"宣传含金量"基本就有数了。记住一句话:
star 数和 README 都可能是表演,代码在不在、能不能跑,才是唯一的实话。
10. 结账:这张抄作业清单请收好
713 行代码,一正一反两本教材。结个总账:
五个可以直接抄走的设计:
| # | 设计 | 一句话版本 | 适用场景 |
|---|---|---|---|
| 1 | 总预算超时 | 每步超时 = 总预算 − 已耗时,余额不足提前收尾 | 任何循环式任务 |
| 2 | 工具分层 | 厂商能托管的工具声明即用,必须本地控制的自己写;沙箱 ID 跨步传递 | Agent 工具箱设计 |
| 3 | 尾部答案提取 | 最终答案 = 最后一次工具调用之后的文字 | 所有 Agent 对话 UI |
| 4 | SSRF 三道锁 | 协议/主机名 → 字面 IP 号段 → DNS 全量解析;记得 169.254、::ffff: 映射 | 任何"替别人发请求"的功能 |
| 5 | 402 报价不付款 | 解析付费价目单规范交给用户,支付环节代码层禁用 | 涉钱、涉不可逆操作的 Agent |
两个小手法 :增强数据用 allSettled + 短超时 + 静默降级;全链路兜底让用户永远拿到体面回复。
两条戒律:永远不写"禁止 AI 承认无知"的提示词;评估开源项目先跑鉴伪四步(grep 宣传词 → 编译 → git 历史 → 测试 import)。
最后回到故事本身。那个项目最讽刺的地方在于:README 里吹的"自研模型""自动支付""多 Agent 协作"都是空气,而被作者当作"不值得保留"删掉的 713 行里,SSRF 三道锁、预算超时、402 解析这些真功夫,比 90% 的同类开源项目写得都好。删掉的是功力,留下的是话术。 下次你看到一个 README 闪着光的项目,先别急着 star------用鉴伪四步摸摸底,然后去 git 历史里挖一挖,说不定宝藏就埋在"Delete"那次提交里。
写作日期:2026-10-22 素材来源:open-bots(GitHub)git 历史 030b9e5~1:api/chat.ts,713 行,MIT 许可的代码部分