CodeGym /课程 /ChatGPT Apps /生产与安全:权限、沙箱、机密、监控

生产与安全:权限、沙箱、机密、监控

ChatGPT Apps
第 12 级, 课程 4
可用

1. 为什么代理需要“生产思维”

当你编写普通后端时,“上线到生产”的想法会自动开启一种“偏执模式”:鉴权、日志、错误处理、限流/限时、机密进 .env 而不是进代码。

对代理也要开启同样的模式,甚至更严格。原因很简单:普通后端只会执行你写好的逻辑,而代理会在既定工具与指令的边界内,执行模型自行决定要做的事。这里的控制幻觉比经典代码更强:看起来 prompt 把一切都说清了,但实际上你只能控制环境与可用动作,而不是模型的全部“想法”。

因此,本讲会逐步为我们的代理加上“防护层”:

  • 先限制它能做什么(工具权限与代理分离),
  • 再隔离执行环境(沙箱与各类限制),
  • 整理机密与 PII,
  • 最后开启可观测性:日志、指标与基础追踪。

为便于讨论,我们继续使用 GiftGenius 这个案例:它是一个帮助挑选礼物的代理,并稍微进入电商世界(能下单与结算,但暂不深入 ACP——后面会讲)。

2. 权限:代理不需要“全世界的按钮”

最小权限原则(Least Privilege)

第一条规则:代理不需要什么都会。工具越多,越有可能在错误的时机调用错误的函数。与其设计一个什么都能读写的巨型 manageEverything(),不如把能力拆成小而清晰的函数,至少划分为读与写。

对 GiftGenius 尤其明显:读取礼物列表与用户偏好是一回事,创建或确认订单(涉及付款)又是另一回事。因此我们通常会这样做:

  • 一组安全的“只读”工具(搜索礼物、查看详情),
  • 单独的“写入”工具(创建订单草稿、取消订单),
  • 如有需要,再加一层“高危操作”(确认支付、批量变更)。

按职责拆分代理

另一个有效做法是按责任边界拆分代理。一个代理负责“礼物推荐”,另一个代理负责“订单管理”。这样即使礼物代理偶尔“跑偏”,它也无法发起支付相关的工具调用,因为这类工具根本不在它的配置里。

给出一个最小化的代理与工具配置类型:

// 为阐明思路而简化的类型
type ToolName = 'suggest_gifts' | 'get_gift_details' |
  'create_order_draft' | 'confirm_order';

type AgentConfig = {
  id: string;
  allowedTools: ToolName[];
  maxSteps: number;
};

现在定义两个 GiftGenius 代理:

export const giftPlannerAgent: AgentConfig = {
  id: 'gift-planner',
  allowedTools: ['suggest_gifts', 'get_gift_details'],
  maxSteps: 6,
};

export const orderAgent: AgentConfig = {
  id: 'order-manager',
  allowedTools: ['create_order_draft', 'confirm_order'],
  maxSteps: 4,
};

是的,这还是抽象层面,但核心很简单:即便代码里有全部四个工具,具体某个代理只会拿到它需要的子集。

将权限绑定到用户与角色

务必记住我们有两类不同的实体:

  • 用户及其权限(该 user_id 是否可以购买、取消、查看历史),
  • 代理及其被允许的工具。

理想情况下,每次工具调用都应通过双重校验:“代理是否被允许?”以及“用户是否也被允许?”。

例如:

type UserRole = 'guest' | 'customer' | 'admin';

function canUserCallTool(role: UserRole, tool: ToolName): boolean {
  if (tool === 'confirm_order') {
    return role === 'customer' || role === 'admin';
  }
  if (tool === 'create_order_draft') {
    return role !== 'guest';
  }
  return true; // 读操作对所有人开放
}

在 MCP/后端处理 tool 调用时,可以做双重校验:

function assertToolAllowed(
  agent: AgentConfig,
  userRole: UserRole,
  tool: ToolName,
) {
  if (!agent.allowedTools.includes(tool)) {
    throw new Error(`Tool ${tool} 已被禁止用于代理 ${agent.id}`);
  }
  if (!canUserCallTool(userRole, tool)) {
    throw new Error(`具有角色 ${userRole} 的用户不能调用 ${tool}`);
  }
}

这样即使模型突然想在错误的代理中或以访客身份调用 confirm_order,调用也会卡在这层校验并变成一条可控的错误,而不是一次意外的支付。

按环境使用不同配置

在 dev 与 staging 环境,往往希望给代理更多自由:测试用工具、假的支付服务、实验性功能。而在 production 中则相反,配置要尽可能严格:部分工具禁用、仅使用正式的 endpoints、仅使用真实 token。

最简单的方案:

type Env = 'dev' | 'staging' | 'production';

const env = (process.env.APP_ENV as Env) ?? 'dev';

const orderAgentByEnv: Record<Env, AgentConfig> = {
  dev: {
    id: 'order-manager-dev',
    allowedTools: ['create_order_draft', 'confirm_order'],
    maxSteps: 8,
  },
  staging: {
    id: 'order-manager-staging',
    allowedTools: ['create_order_draft', 'confirm_order'],
    maxSteps: 6,
  },
  production: {
    id: 'order-manager-prod',
    allowedTools: ['create_order_draft'], // confirm 只通过单独路径
    maxSteps: 4,
  },
};

export const currentOrderAgent = orderAgentByEnv[env];

在生产中,confirm_order 甚至可以被完全拆到单独的“高危”代理中,且只有在小部件里用户明确点击“确认订单”并通过额外校验后才会调用。

3. 沙箱:代理不需要对你的世界有 root 级访问

隔离层级

在为代理与用户设定完权限之后,下一层防护是沙箱与执行环境隔离。

代理与其工具的沙箱可以粗分为几个层级:

  1. 工具代码层级。 限制对文件系统、网络与进程资源的访问:不允许随意写文件、访问任意域名、无限占用 CPU 或内存。
  2. Agents SDK 层级。 为运行循环设定步数上限、工具调用次数上限以及上下文大小(token limit)。模型不能无限“思考”或无限制造 tool-calls——到达阈值时运行会以“步数超限”或“超时”结束。

这些共同构成经典的“防御式架构”,可以用示意图表示。

graph TD
    A[提示词 / system 指令] --> B[工具的 JSON Schema]
    B --> C[代理与用户的权限]
    C --> D[基础设施的 Sandbox]
    D --> E[外部服务 / 数据库]

    subgraph 代理
      A
      B
      C
    end

    subgraph 基础设施
      D
    end

Prompt 是最弱的防护;真正的力量来自你对代码可做之事与可访问 API 的物理限制。

对运行循环设限:步数、时间、tool-calls

部分沙箱能力可以直接体现在代理配置里:最大步数、总体执行时间、工具调用次数上限。这不仅能防止“失控”循环,还能控制成本。

一段抽象的运行选项配置示例:

type RunLimits = {
  maxSteps: number;
  maxToolCalls: number;
  timeoutMs: number;
};

const defaultLimits: RunLimits = {
  maxSteps: 8,
  maxToolCalls: 10,
  timeoutMs: 30_000,
};

随后你把这些限制传给启动代理的封装层。如果模型决定第 11 次调用某个工具——你就中断代理运行,并明确告知用户“任务过于复杂”,而不是放任它无节制地烧预算。

代码与网络隔离

在容器/进程层面的常见实践包括:

MCP 服务器与/或代理服务的代码在容器中运行:文件系统为只读(除非有专门的工作目录),并限制计算资源(CPU、RAM)。网络访问基于 allow-list 配置:只能访问必要的外部服务(你的电商后端、支付服务、少量外部 API),而不是任意互联网。

对于代理场景这点尤其关键:模型可能尝试访问某个“乱七八糟”的 API 或读取意料之外的文件,而即便它尝试了,也应在物理权限层面无法触及多余资源。

在代码层面,这往往不是一行“神奇的 TypeScript”,而是你的编排平台(Docker Compose、Kubernetes、Vercel、Fly.io 等)的配置。但在设计阶段就提前考虑这些非常有价值:

  • 能够执行外部代码的工具(例如包含 shell 命令的报告生成)必须在单独且严格隔离的环境中运行;
  • 工具不应能读取其他人的文件、机密、配置;
  • 网络访问最好按域名或 IP 明确限制。

4. 秘密与敏感数据:代理不该知道的那些事

机密应当存在哪里

基本规则:任何机密——API 密钥、密码、access token——都不应进入模型的 prompt、小部件、日志或代码仓库。它们应当存放在:

  • 环境变量(process.env.SOMETHING)中,
  • 密钥管理器中(AWS Secrets Manager、GCP Secret Manager、Vault 等),
  • 独立加密的存储中,且访问受严格控制。

在我们的 GiftGenius 中,例如会有电商 API 的商店密钥。我们需要让代理通过 MCP 工具创建订单草稿,但模型本身不应看到这个密钥。

// mcp/tools/createOrderDraft.ts
const COMMERCE_API_KEY = process.env.COMMERCE_API_KEY!;

export async function createOrderDraft(args: {
  userId: string;
  giftId: string;
  quantity: number;
}) {
  // 模型永远看不到 COMMERCE_API_KEY —— 它只存在于此处的服务器端
  const res = await fetch(`${process.env.COMMERCE_API_URL}/orders/draft`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${COMMERCE_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(args),
  });

  if (!res.ok) {
    throw new Error(`Commerce API returned ${res.status}`);
  }

  return res.json(); // 返回给代理的将是已脱敏的安全对象
}

重要:工具的返回值中不要“顺带”传出密钥或其他敏感细节。代理只需要知道 draftOrderId、条目列表,可能再加一个状态即可。

PII 与上下文最小化

除了机密,还有一类是 PII(用户个人数据):姓名、电话、收货地址、邮箱等。代理通常不需要这些“原始”文本。一个结构化的画像就足够了:“喜欢桌游”“年龄 30–35”“预算约 50–70 美元”。

与其把用户的完整订单历史塞进 prompt,不如做一个工具 get_user_profile_summary,只返回汇总且去标识化的用户画像。

type ProfileSummary = {
  ageRange: '18-25' | '26-35' | '36-50' | '50+';
  interests: string[];
  preferredBudget: { min: number; max: number };
};

export async function getUserProfileSummary(userId: string): Promise<ProfileSummary> {
  // 这里访问数据库,但对外只返回聚合后的信息
  return {
    ageRange: '26-35',
    interests: ['桌游', '数码产品'],
    preferredBudget: { min: 30, max: 80 },
  };
}

模型只会看到为完成选礼任务所必需的信息,不多也不少。

日志脱敏(Scrubbing)

日志是最容易“意外”泄露机密与 PII 的地方。尤其当你写了一个“顺手”的记录器,比如直接用 console.log(...) 打印“一切”。

一种好的方式是使用集中式记录器,在打印之前遍历载荷,屏蔽敏感字段。

type LogPayload = Record<string, unknown>;

const SENSITIVE_KEYS = ['email', 'phone', 'cardNumber', 'token'];

function scrub(payload: LogPayload): LogPayload {
  const result: LogPayload = {};
  for (const [key, value] of Object.entries(payload)) {
    if (SENSITIVE_KEYS.includes(key)) {
      result[key] = '***redacted***';
    } else {
      result[key] = value;
    }
  }
  return result;
}

export function logEvent(event: string, payload: LogPayload) {
  const safe = scrub(payload);
  console.log(JSON.stringify({ event, ...safe }));
}

这样,你就不会在某天遭遇生产事故:“我们已经把用户的电话与 token 打到日志里半年了”。这不仅是工程严谨度问题,也是合规(GDPR 与本地法规)的要求:日志中的 PII 越少,产品越省心。

5. 代理的监控与可观测性

需要观测什么

我们已经限制了代理能做什么、能看到哪些数据,以及日志会包含什么。接下来,如何判断代理在生产中的行为是否符合预期?

传统“服务存活/不存活”的监控对代理几乎没用。我们不仅要知道进程是否活着,更要理解其行为:走了哪些步骤,调用了哪些工具,哪里出错,哪里陷入循环。

每次运行的最小数据集:

  • agent_run_id —— 运行的唯一标识;
  • 匿名的 user_id 或会话 ID;
  • 代理名与环境;
  • 被调用的工具清单:名称、次数、总耗时;
  • 工作流步骤,以及停在哪一步;
  • 最终状态: success、 partial_success、 failed、 canceled、 timeout、 limits_exceeded。

可以将其建模为如下结构:

type RunStatus =
  | 'success'
  | 'partial_success'
  | 'failed'
  | 'canceled'
  | 'timeout'
  | 'limits_exceeded';

type ToolCallLog = {
  name: ToolName;
  durationMs: number;
  success: boolean;
};

type AgentRunLog = {
  runId: string;
  agentId: string;
  userId: string;
  env: Env;
  startedAt: string;
  finishedAt: string;
  status: RunStatus;
  toolCalls: ToolCallLog[];
  errorMessage?: string;
};

围绕代理启动的“包装器”示例

假设你有一个封装了真实 Agents SDK 调用的函数 runAgent。我们来给它加上监控:

async function runAgentWithLogging(
  agent: AgentConfig,
  input: string,
  userId: string,
): Promise<string> {
  const runId = crypto.randomUUID();
  const startedAt = new Date();

  const toolCalls: ToolCallLog[] = [];

  try {
    const result = await runAgent(agent, input, {
      userId,
      limits: defaultLimits,
      onToolCall: (name, durationMs, success) => {
        toolCalls.push({ name, durationMs, success });
      },
    });

    const finishedAt = new Date();

    const log: AgentRunLog = {
      runId,
      agentId: agent.id,
      userId,
      env,
      startedAt: startedAt.toISOString(),
      finishedAt: finishedAt.toISOString(),
      status: 'success',
      toolCalls,
    };

    logEvent('agent_run', log);
    return result;
  } catch (err) {
    const finishedAt = new Date();
    const log: AgentRunLog = {
      runId,
      agentId: agent.id,
      userId,
      env,
      startedAt: startedAt.toISOString(),
      finishedAt: finishedAt.toISOString(),
      status: 'failed',
      toolCalls,
      errorMessage: (err as Error).message,
    };
    logEvent('agent_run', log);
    throw err;
  }
}

这里的 runAgent 是一个黑盒,可以用任意 Agents SDK 实现;我们只是展示如何在不依赖具体 API 的情况下加上可观测性。

日志 vs 指标 vs 追踪

把可观测性拆成三个层级通常更清晰:

层级 是什么 GiftGenius 代理示例
日志 关于具体运行的“故事” 带步骤与工具的详细 AgentRunLog
指标 聚合的数值指标 运行时长 p95、平均 tool-calls 数、错误率
追踪 请求与子请求的树/图 Run → 步骤 → tool-calls → 外部 API 调用(commerce、数据库等)

指标用来回答“总体还好吗?”(比如最近一小时的错误率)。日志与追踪帮助定位“为什么这里不好?”并复现具体的异常运行。

可以先基于日志做一个“萌芽版”指标:定时任务聚合 agent_run 事件,计算运行时长 p95、错误数量等。

6. 在 GiftGenius 中的整体落地

为避免显得过于抽象,我们把上述内容整合到示例应用中。

生产环境中的 gift-planner 代理仅拥有安全的工具:礼物推荐与详情获取。它看不到支付与订单管理。其 system 指令会明确要求不要向用户承诺“我会帮你把钱付了”,至多是给出推荐,或准备一个礼物清单草稿。

order-manager 代理是独立存在的,只负责订单。在生产环境中它只能创建订单草稿(create_order_draft),而订单确认(confirm_order)要么由用户在小部件里明确触发,要么只在 dev/staging 可用。其工具仅在后端使用商店的 API 密钥,并且只在响应中代理必要字段。

两个代理都通过 runAgentWithLogging 这层封装启动,该封装会施加限制并记录包含 agent_run_id、userId、环境与工具列表的日志。日志中没有邮箱或电话;这些字段会被脱敏器提前清理。用户画像以去标识化方式使用:年龄段、兴趣、预算,而不是完整的购买历史。

承载 MCP 服务器与代理服务的基础设施是隔离的:容器使用只读文件系统(除 /tmp 或专门目录外)、限制 CPU/RAM、网络按 allow-list 配置域名。若代理尝试访问“莫名其妙”的外部资源,它在物理层面也无法触达。

如果你某个时刻发现“状态为 limits_exceeded 的运行比例”突然升高,或“平均 tool-calls 数 > 10”,就可以判断要么 prompt 过于啰嗦,要么某个工具异常,导致代理不断重试步骤。

这已经是一个成熟服务的行为方式,而不是“先能跑就行”的试验性代理。

7. 将代理上线到生产时的常见错误

以上是“理想形态”的生产级代理。而在实际中,最常见的是一些“坑”。把它们汇总如下:只要避免这些错误,上线会顺畅得多。

错误 1:代理被“授予一切”。
常见场景:你定义了一堆 MCP 工具(搜索、修改、删除、支付),而创建代理时直接把整个列表都给了它。结果模型可能在只需要读取的场景里误调用删除或支付。解决办法是按角色划分工具,并拆成多个更窄的代理,每个代理都有自己的 allowedTools。

错误 2:只在 prompt 中做权限校验。
有些开发者会在 system 指令里写“从不在未经用户确认的情况下购买任何东西”,然后就觉得万事大吉。但 prompt 是弱防护,jailbreak 与普通失误都难以避免。必须在后端做真实校验:“代理是否允许该工具”“用户是否允许该工具”。否则一次不严谨的生成就可能带来意料之外的动作。

错误 3:机密出现在 prompt 与日志中。
有时为了“加快集成”,把 API 密钥放进 system-prompt,或通过 tool 参数交给代理,让它自行调用外部 API。最终密钥既出现在模型的日志,也可能泄露到第三方系统。这是直通泄露与在商店被封禁的道路。机密必须只存在于服务器端、环境变量或密钥管理器中,绝不进入模型上下文。

错误 4:原始日志未做脱敏。
调试时用 console.log(...) 很方便,也很容易被遗忘。几个月后发现日志里堆满了用户地址、电话、带 PII 的订单号。在 GDPR 与其他监管下尤其麻烦。最好一开始就建立集中式记录器,并实现自动字段脱敏,即便你觉得“只在 dev 打日志”。

错误 5:缺乏对代理行为的限制。
如果没有步数、时间与工具调用次数的限制,代理可能陷入循环:反复调用同一工具、无限尝试修复同一个错误、消耗大量 tokens 并拖垮外部 API。最好的情况是你收到账单暴涨,最坏是后端被拖垮、用户大面积不满。对运行循环的限制与合理的超时默认值是配置的必选项。

错误 6:在同一工具中混合读与写。
有时会写出类似 getOrCreateOrder 的“方便方法”,在没有订单时顺便创建。对传统后端这可能是可接受的模式,但在代理世界里会产生意外副作用:模型本想读取状态,工具却创建了新东西。更安全的做法是拆分 get_order_details 与 create_order_draft,即便发生重复调用,后果也更可控。

错误 7:忽视可观测性。
很多人从“先跑起来,日志与指标以后再说”开始。没有监控的代理就是黑箱:你不知道它调用了哪些工具、走了多少步、在哪出错。任何用户投诉都会变成在黑屋里办案。与其事后在混乱代码上补齐,不如一开始就设计好日志结构(agent_run_id、tools、状态)与基础指标。

1
任务
ChatGPT Apps, 第 12 级, 课程 4
已锁定
用于服务器事件的清洗日志记录器(PII/Secrets-safe)
用于服务器事件的清洗日志记录器(PII/Secrets-safe)
1
任务
ChatGPT Apps, 第 12 级, 课程 4
已锁定
用于 tool-call 的双重权限检查(agent permissions + user role)
用于 tool-call 的双重权限检查(agent permissions + user role)
1
调查/小测验
代理编排第 12 级,课程 4
不可用
代理编排
使用 Agents SDK 的代理编排
评论
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION