为什么先从 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。
你将构建什么
到最后你会得到:
- 一个从每个请求中提取 actor 证据的中间件。
- 一个小而明确的分类结果,并为每个结果注明依据。
- 结构化观察日志,且绝不改变请求的处理方式。
- 一种把结果附加到请求上的方式,供后续 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 中说明。
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 步: 在中间件中提取证据
提取步骤读取头和请求状态。此时还不做任何判断。
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。
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 步: 以观察模式接入
尽早挂载中间件,把结果附加到请求上,并记录日志。不要基于它分支处理。
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();
});下面三个细节让这件事是安全的:
try块保证检测失败不会影响响应。观察必须在失败时保持开放。- 记录发生在
finish,因此你捕获到的是真实状态码,之后可以将分类与结果进行比较。 - 在实现真正的验证之前,
verifySignature会暂时设为false。这样可以把已签名请求留在 declared 或 unknown 桶里,而不是夸大其程度。
第 6 步: 为分析而不是只为调试而记录日志
写出可以聚合的日志。每个请求一行结构化记录就足够了。
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。这些用途能改善体验,而不会让任何人被锁在外面。
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 如何工作。
来源
- RFC 9421: HTTP Message Signatures: 已签名请求背后的标准
- draft-ietf-webbotauth-httpsig-protocol: IETF Web Bot Auth 草案,定义了
Signature-Agent头(截至 2026 年 11 月仍在进行中) - RFC 9110: HTTP Semantics, User-Agent and From: 自我报告的 client 身份头
- MCP specification 2025-11-25: Lifecycle:
initialize请求和clientInfo - MCP specification 2026-07-28: Versioning and Compatibility: 每个请求的版本、身份和能力元数据
- Anthropic: how Anthropic-hosted browsers identify themselves: user agent token、已发布的 IP 地址,以及该头可以被复制的警告