跳转到主要内容
指南

如何将 actor 上下文添加到 Node.js 应用

一步一步构建中间件,记录每个请求背后是谁或什么,从而在执行任何强制动作之前先研究真实流量。

Toshendra Sharma

作者 Toshendra Sharma创始人兼 CEO,AgentCredia · Updated November 13, 2026

入门 · 9 分钟阅读

为什么先从 actor 上下文开始,而不是从规则开始?

大多数注意到流量里有 AI agent 的团队,第一反应是先写规则。屏蔽一个 user agent,增加一个 challenge,收紧 rate limit。问题在于,在你真正理解流量之前写下的规则,往往会打到错误的人。合法的助手会被拦住,真实用户会被 challenge,而你真正担心的自动化,因为一开始就没有表明身份,反而继续进来。

actor 上下文是规则之前的一步。对每个请求,你记录应用能够观察到的、关于是谁或什么在行动的信息: 一个交互中的人,一个与 AI 助手协作的人,一个自主 agent,或者一个你暂时无法分辨的来源。你记录这种判断背后的证据,并把它和请求结果放在一起。经过一两周之后,你会得到任何规则模板都给不了你的东西: 你自己流量的地图。如果你仍在判断是否真的有 agent 访问你的网站,可以先看 如何判断网站流量是否来自 AI agent。

本指南使用普通中间件在 Node.js 应用中搭建这一层。它适用于 Express,同样的思路也可以迁移到 Fastify、Koa、Hono 或 Next.js route handler。

你将构建什么

到最后你会得到:

  1. 一个从每个请求中提取 actor 证据的中间件。
  2. 一个小而明确的分类结果,并为每个结果注明依据。
  3. 结构化观察日志,且绝不改变请求的处理方式。
  4. 一种把结果附加到请求上的方式,供后续 handler 读取。

这里不会阻止任何请求。这是有意为之。

第 1 步: 你的服务器实际上能看到什么证据?

服务器看到的比人们想象得少。在写代码之前,先列出你在边缘侧能够获得的信号:

  • 请求签名。 一些自动化客户端会使用 HTTP Message Signatures (RFC 9421) 对请求签名,发送 Signature-Input 和 Signature 头,通常还会带上一个指向运营方已发布密钥的 Signature-Agent 头,如 IETF Web Bot Auth protocol draft 中所述。
  • 声明身份。 客户端可能会在 User-Agent 头、在 From 头中,或者通过协议元数据例如 MCP client name 来声明自己。MCP client 会在 2025-11-25 修订版的 initialize request 中报告一个自行选择的 clientInfo 名称,而 2026-07-28 修订版则将 client 身份作为 per-request metadata 传递。这些都是声明,任何人都可以复制。
  • 认证上下文。 请求是否携带 session cookie、bearer token 或 API key,以及它属于哪一类凭据。
  • 协议形态。 Accept 头、客户端请求的是 HTML 还是 JSON、是否加载页面资源,以及它如何处理重定向和 cookie。
  • 应用声明。 你的产品本身可能知道网络层看不到的事情,例如用户选择了一个 "AI assisted" 选项,或者请求来自你内置的助手功能。

每个信号的强度都不同。经过验证的签名是可校验的。user agent 字符串只是一个声明。缺少 cookie 只是缺少而已。把这些区别开来,正是这个练习的核心。

第 2 步: 显式建模结果

在写检测逻辑之前,先定义 actor 结果的形状。一个小而有类型的模型,可以让每个人都清楚一个分类到底意味着什么。这里使用的四类已经在 Human, AI-assisted or agent: a practical taxonomy for application traffic 中说明。

TS
export type ActorClass = "human" | "human_ai" | "agent" | "unknown";
export type EvidenceBasis = "verified" | "declared" | "inferred" | "unknown";

export interface ActorEvidence {
  signaturePresent: boolean;
  signatureAgent?: string;
  declaredClient?: string;
  credentialType: "session" | "api_key" | "bearer" | "none";
  acceptsHtml: boolean;
  appDeclaredAssist: boolean;
}

export interface ActorResult {
  actor: ActorClass;
  basis: EvidenceBasis;
  reasons: string[];
  evidence: ActorEvidence;
  observedAt: string;
}

最重要的是两个字段。actor 是类别。basis 说明你为什么这样判断。基于经过验证的签名得到的 agent 结果,和基于 user agent 字符串得到的 agent 结果,虽然都属于 "agent",但后续应该有完全不同的处理,所以不要把它们混为一谈。

第 3 步: 在中间件中提取证据

提取步骤读取头和请求状态。此时还不做任何判断。

TS
import type { Request, Response, NextFunction } from "express";

const KNOWN_AGENT_TOKENS = ["bot", "crawler", "spider", "agent", "gpt", "claude"];

export function extractEvidence(req: Request): ActorEvidence {
  const ua = (req.get("user-agent") || "").toLowerCase();
  const auth = req.get("authorization") || "";
  const hasSession = Boolean(req.cookies?.session);

  let credentialType: ActorEvidence["credentialType"] = "none";
  if (hasSession) credentialType = "session";
  else if (auth.toLowerCase().startsWith("bearer ")) credentialType = "bearer";
  else if (req.get("x-api-key")) credentialType = "api_key";

  const declaredToken = KNOWN_AGENT_TOKENS.find((t) => ua.includes(t));

  return {
    signaturePresent: Boolean(req.get("signature") && req.get("signature-input")),
    signatureAgent: req.get("signature-agent") || undefined,
    declaredClient: declaredToken ? ua.slice(0, 200) : undefined,
    credentialType,
    acceptsHtml: (req.get("accept") || "").includes("text/html"),
    appDeclaredAssist: req.get("x-app-assist") === "1",
  };
}

关于这段代码,有几点说明:

  • signaturePresent 只记录签名头是否存在。验证是单独的一步,已经在验证已签名 agent 请求的指南中说明。
  • user agent token 列表故意保持简单。它产生的是一个声明,而不是验证。
  • x-app-assist 用来代表你的前端在用户通过内置 AI 功能操作时发送的任何内容。请把它替换成你自己的真实机制。

第 4 步: 以明确的依据进行分类

现在把证据转化为结果。保持规则简短,按证据强度从强到弱排序,并且始终回退到 Unknown。

TS
export async function classify(
  evidence: ActorEvidence,
  verifySignature: () => Promise<boolean>,
): Promise<ActorResult> {
  const reasons: string[] = [];
  const observedAt = new Date().toISOString();

  if (evidence.signaturePresent && (await verifySignature())) {
    reasons.push("request signature validated");
    return { actor: "agent", basis: "verified", reasons, evidence, observedAt };
  }

  if (evidence.appDeclaredAssist) {
    reasons.push("application reported its built-in assistant");
    return { actor: "human_ai", basis: "declared", reasons, evidence, observedAt };
  }

  if (evidence.declaredClient) {
    reasons.push("client declared itself as automated");
    return { actor: "agent", basis: "declared", reasons, evidence, observedAt };
  }

  if (evidence.credentialType === "session" && evidence.acceptsHtml) {
    reasons.push("interactive browser session");
    return { actor: "human", basis: "inferred", reasons, evidence, observedAt };
  }

  reasons.push("insufficient evidence");
  return { actor: "unknown", basis: "unknown", reasons, evidence, observedAt };
}

注意这里没有做什么。它不会把声明升级为验证。它也不会把没有交互信号的请求标成 Human。当它无法判断时,就说 Unknown。Unknown 是一个有效且有用的答案,它精确地告诉你可见性的边界在哪里。我们在 为什么 Unknown 是 AI 流量的有效结果 中详细说明了这一点。

第 5 步: 以观察模式接入

尽早挂载中间件,把结果附加到请求上,并记录日志。不要基于它分支处理。

TS
import express from "express";
import cookieParser from "cookie-parser";

const app = express();
app.use(cookieParser());

app.use(async (req, res, next) => {
  try {
    const evidence = extractEvidence(req);
    const result = await classify(evidence, async () => false);
    res.locals.actor = result;

    res.on("finish", () => {
      logActor({
        route: req.route?.path ?? req.path,
        method: req.method,
        status: res.statusCode,
        actor: result.actor,
        basis: result.basis,
        reasons: result.reasons,
      });
    });
  } catch {
    res.locals.actor = undefined;
  }
  next();
});

下面三个细节让这件事是安全的:

  1. try 块保证检测失败不会影响响应。观察必须在失败时保持开放。
  2. 记录发生在 finish,因此你捕获到的是真实状态码,之后可以将分类与结果进行比较。
  3. 在实现真正的验证之前,verifySignature 会暂时设为 false。这样可以把已签名请求留在 declared 或 unknown 桶里,而不是夸大其程度。

第 6 步: 为分析而不是只为调试而记录日志

写出可以聚合的日志。每个请求一行结构化记录就足够了。

TS
function logActor(entry: Record<string, unknown>) {
  process.stdout.write(JSON.stringify({ type: "actor_observation", ...entry }) + "\n");
}

在这些日志中避免包含个人数据。你需要的是 route、类别、依据、原因和结果。不需要完整 IP 地址、原始 cookie 或请求正文,也能理解你的流量结构。如果你要保留任何用于关联的标识符,请用轮换 salt 做哈希,并设置较短的保留期。

第 7 步: 在采取行动前先读数据

以观察模式运行足够长的时间,观察正常的每周模式。然后回答几个问题:

  • 哪些 routes 更容易吸引 agent? 文档、目录和搜索 endpoint 通常会。账户和结账流程通常不应该。
  • agent 结果中 verified 与 declared 各占多少? 这个比例告诉你在编写策略时,身份判断可以多大程度上被安全依赖。
  • Unknown 主要集中在哪里? 某个 route 上 Unknown 比例很高,通常意味着那里缺少某个信号,例如一个从不运行你前端代码的页面。
  • 不同类别的结果是否不同? 比较各类之间的错误率、转化步骤或滥用报告。真正的策略想法通常就来自这里。

这些答案常常会让人意外。团队经常发现,所谓的很大一部分 "bot problem",其实是合法自动化。如果存在结构化 endpoint,它们其实很愿意使用。

第 8 步: 把结果暴露给应用的其他部分

一旦数据开始流动,就让 handler 读取 res.locals.actor,用于不执行强制的场景: 分析标签、选择响应格式,或者记录一张 support ticket 来自 AI-assisted session。这些用途能改善体验,而不会让任何人被锁在外面。

TS
app.get("/docs/:page", (req, res) => {
  const actor = res.locals.actor as ActorResult | undefined;
  if (actor?.actor === "agent" && req.accepts("text/markdown")) {
    return res.type("text/markdown").send(renderMarkdown(req.params.page));
  }
  return res.render("doc", { page: req.params.page });
});

强制执行,例如 challenge、rate limit 或 step-up check,应当放在后面,在你测量过误报之后再进行。关于如何逐步上线 actor-aware 策略的指南,会覆盖这一过程。

你应该避免哪些错误?

  • 把 user agent 当作身份。 这是最容易被改写的头。应把它作为声明来记录。连 AI 运营方也这么说: Anthropic 关于其托管浏览器 agent 的 guidance 指出,任何人都可以发送相同的 User-Agent 头,并建议在把流量视为真实之前先检查其公开的 IP 地址。
  • 默认判定为 Human。 如果你的代码在找不到 bot 信号时就总是判定为 Human,你的 Human 数字会被夸大,你的策略也会信任错误的流量。
  • 混淆类别与依据。 一定要同时保存两者。只显示 "Agent" 的 dashboard 会掩盖经过验证的 operator 和复制来的字符串之间的差异。
  • 在观察期间进行阻止。 即使是临时封禁也会使测量失效,因为被阻止的客户端会改变它们的行为。

Proof of Actor 在哪里发挥作用

上面的中间件是一个扎实的起点,它展示了核心模型: 先看证据,为每个结果给出明确依据,保持 Unknown 可见,把决定留给你的应用。Proof of Actor 基于同样的模型,扩展了 server、edge、browser 和 MCP 场景中的信号覆盖,并提供分析能力,让你能够更有把握地 Detect、Understand 和 Route。

如果你想把这一步继续推进到生产应用中, 申请早期访问。要查看完整的决策流程,请阅读 Proof of Actor 如何工作。

来源

与我们一起构建。

加入 Proof of Actor founding developer program。

想先聊聊? 联系创始人