我从"删掉核心代码"的开源项目里,学到了一堂 713 行的 Agent 工程课

我从"删掉核心代码"的开源项目里,学到了一堂 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)不能外包------因为你需要自己过安全检查。

判断标准就一条:这个工具的执行需不需要你控制? 搜索、跑通用代码不需要控制,托管;访问外部世界(尤其要过安全检查的),自己来。

划重点

模型厂商提供的服务端工具,能声明就声明,沙箱和安全白嫖官方的;只有必须本地控制的工具才自己写执行逻辑。跨步骤保持 container ID,让沙箱有记忆。


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 许可的代码部分

相关推荐
%KT%2 小时前
Agent开发过程中的会话管理
agent
jerrywus2 小时前
给 Pi 装上生图插件:从登录报错到画出一只 200×200 的猫
前端·agent·claude
灵动的艺术4 小时前
如一 Agent 架构解读(二):上下文工程——为模型构造它唯一的现实
人工智能·架构·agent
老周聊架构5 小时前
create_agent 实战:一个 Agent 是怎么被组装出来的
agent
灵动的艺术5 小时前
如一 Agent 架构解读(六):阈值、联动与坑位——压缩系统的肌理
人工智能·agent·如一
武子康6 小时前
把调用移进同一个类之后:一次 Spring 事务回滚反例
人工智能·后端·agent
百慕大三角6 小时前
为了在 Linux CI 里跑小程序 E2E,我把微信官方基础库搬进了浏览器:wxa-runner 开源实践
微信小程序·agent·测试
德林恩宝6 小时前
搜索 Agent 的两条路——训练一个,还是工程搭一个
人工智能·大模型·llm·agent
进击的雷神7 小时前
怕 AI Agent 乱删文件?Tank-OS 把它锁进不可变系统
人工智能·ai·api·agent