自动化评分(真人 vs. 脚本)

自动化评分用 0.0–1.0 之间的分数,衡量每个用户的 API 流量更像脚本驱动还是真人驱动:

  • HIGH(→ 1.0)——流量主要由自动脚本 / 批处理任务 / cron 驱动。

  • LOW(→ 0.0)——有人在交互式地使用服务(聊天界面,或 Claude Code 这类由人驱动的编码 agent)。

它是用来初筛的启发式指标,不是定论。一定要结合返回的 confidence 和各信号的明细一起看。

备注

这个功能会分析单个用户的流量模式。每算一次评分,都要读取这个用户在时间窗口内的 api_logs 行:什么时候发的请求、prompt 有多长、用的哪个客户端,以及为最新一条用户消息预先算好的形态统计(长度、字符熵和一个哈希,见信号 7)。只有下文的管理端点和运维 CLI 能调用它,用户本人不能,任何公开路由也不能。如果部署在真实流量上使用这个功能,应当在自己的隐私政策里写明,并且只把评分当作人工决策的参考,不要据此自动处置。

输入

评分只看最近一段时间窗口内的数据(默认 30 天;管理端点的 days 参数可取 1..90)。只统计 user_id 非空的 api_logs 行,匿名流量不计入。每个用户会聚合出以下指标:

  • 请求数 N,以及 n_chat = num_user_turns IS NOT NULL 的行数(聊天型请求;embedding / 原始 completion 为 NULL);

  • 单轮请求数(num_user_turns = 1)以及 num_user_turns 的 p90;

  • 工具调用计数(num_tool_calls IS NOT NULL,以及 > 0);

  • 大于 0 的 prompt_tokens 的 25/50/75 分位数,以及 n_sz = prompt_tokens > 0 的行数(prompt 长度类信号靠它判断是否可用);

  • 带编码 agent 开场白的请求占比(存在 metadata->>'agent'——见下文 agent_opener_override 信号);

  • 按请求加权的 metadata->>'user_agent' 分布;

  • UTC 小时直方图(24 个桶);

  • 相邻请求之间到达间隔的 25/50/75 分位数。

各个信号

评分由七个信号融合而成。每个信号把自己的原始指标映射成 [0, 1] 之间的自动化子分(HIGH = 像自动化),并带有一个默认权重。这个功能最初是围绕四个信号设计的:用户轮次、轮次长度、user-agent 和日活动形态;另有两个小的辅助信号,专门用来区分最容易混淆的一类用户(流量很大、但由真人驱动的编码 agent);user_message_shape 则专门看用户自己写的消息。

信号

维度

默认权重

可用条件

turn_pattern

用户轮次

0.24

n_chat ≥ 5

prompt_size_dispersion

prompt 总长度

0.17

n_sz ≥ 8 且中位数 > 0

user_message_shape

用户消息长度与熵

0.15

下述分项至少有 1 个可用

client_tool_prior

user-agent

0.16

始终(≥ 1 个请求)

daily_activity_shape

日活动形态

0.27

下述时间分项至少有 1 个可用

tool_call_human_tell

(辅助)

0.08

n_chat ≥ 5 且存在工具调用

agent_opener_override

(辅助)

0.08

agent_share ≥ 0.05

某个用户的数据不足以算出某个信号时,这个信号会被丢弃,剩下的权重在其余信号之间重新归一化。缺失的信号绝不会按 0 补上,否则会把评分错误地拉向「真人」。这些权重加起来不必等于 1.0(实际总和是 1.15),运行时总是除以实际可用的权重之和。如果用户的请求全都发生在用户消息那几列加上之前,就只是缺少 user_message_shape:其余六个信号照常融合,confidence 会略低一些。

1. turn_pattern——用户轮次

交互式会话每次都会把越来越长的历史重发一遍,所以 num_user_turns 会按 1, 2, 3, … 往上涨;而脚本一个接一个地发互相独立的单轮 completion,几乎每个请求都是 num_user_turns = 1。

f1          = one_shot_chat_requests / n_chat          # fraction stuck at 1 user turn
depth_factor = 0.5 if p90(num_user_turns) >= 3 else 1.0
sub          = clamp01(f1 * depth_factor)

如果用户确实有过较深的多轮对话(p90 ≥ 3),depth_factor 会把子分减半。这样,一个真人即使同时也发了大量单轮请求,也不会被当成脚本。

2. prompt_size_dispersion——用户轮次长度

这里用的是 prompt_tokens。按 OpenAI 的语义,它是整个请求的输入总量:system prompt + 本轮重发的整段对话历史 + 工具定义 + 最新一条用户消息,而不单是用户消息本身(api_logs 里没有按角色拆分的 token 明细)。要衡量「用户轮次长度」,这是唯一能用的替代指标。模板化的自动化流量,每个请求都由固定模板拼出来,所以 prompt 总长度非常集中;而真人交互时,请求长短差别很大(先是一句话的追问,接着又粘贴一大段)。因此判别依据是总长度的稳健相对离散度(IQR / 中位数):它与量纲无关,也不会被偶尔一次粘贴的超长 prompt 带偏:

rcv = (p75 - p25) / median        # over positive prompt_tokens
sub = clamp01(1 - rcv / 0.5)      # rcv >= 0.5 -> 0 (human-varied); rcv = 0 -> 1 (templated)

3. client_tool_prior——user-agent

User-Agent 是最容易伪造的信号,所以它只是一个权重很低的软性先验,从不起决定作用。每个请求的 UA 都会归入一个客户端类别(逻辑移植自前端的 parseClientTool),每个类别对应这个请求的自动化取值:

客户端类别

示例

取值

交互式 / 编码 agent

claude-code, cline, cursor, codex, browser

0.10

无法区分的 SDK

openai-python, openai-node, anthropic-python/-sdk

0.50

原始 HTTP 库 / API 工具

python-requests, httpx, aiohttp, curl, wget, okhttp, axios, go-http, postman, …

0.85

未知(能识别出前导 token)

myagent/1.0

0.60

缺失 / 无法识别的 UA

—

0.70

ua_base = request-weighted mean of the per-request class values
sub     = clamp01(ua_base * (1 - 0.85 * min(agent_share, 1)))

SDK 取中性的 0.5,因为真人用的聊天界面底下也完全可能是 openai-python。有了 agent_share 这一项,即使 UA 看起来很像脚本,编码 agent 的开场白(见下文)也能把先验拉向真人。

4. daily_activity_shape——日活动形态

这是最难大规模伪造的行为指纹。最多由四个分项融合而成,只在数据量达到下限的分项之间重新归一化。每一项都是 HIGH = 自动化:

分项

权重

公式

数据下限

小时覆盖率

0.20

clamp01((coverage - 0.5) / 0.5),其中 coverage = distinct active UTC hours / 24——有活动的不同 UTC 小时数除以 24

N ≥ 10

小时熵

0.20

clamp01((Hnorm - 0.5) / (0.92 - 0.5)),其中 Hnorm = ShannonEntropy(hours) / log2(24)。这里的 0.92 是个调参常数,到这个值时这一分项就饱和了

N ≥ 10

夜间休息间隙

0.30

clamp01(1 - max_quiet_gap_hours / 6)

N ≥ 10

到达间隔规律性

0.30

clamp01(1 - gap_rcv / 1.0),其中 gap_rcv = (p75 - p25) / median,按到达间隔计算

≥ 3 gaps

max_quiet_gap_hours 是一天 24 个钟点里最长的连续无活动时段,按环形计算(跨零点也算连续)。真人夜里睡觉留下的空档会把休息间隙这一分项压向 0,而 7×24 小时运行则完全没有空档(→ 1)。与时区无关的几个分项(规律性、休息间隙、熵)占了大部分权重,所以即使不知道用户所在的时区,也只会让直方图平移,不会扭曲结论(小时按 UTC 分桶)。

5. tool_call_human_tell——辅助信号

Agent 式的工具调用(num_tool_calls > 0,来自 OpenAI 的 tool_calls / Anthropic 的 tool_use 块)是真人驱动编码循环的典型特征。它只能单向证明是真人:完全没有工具调用时,这个信号被整个丢弃(不用工具并不说明是自动化);有工具调用时,它也只能把评分拉向真人,绝不会抬高评分:

sub = clamp01(0.5 - toolcall_share)   # share>=0.5 -> 0 (strongly human); share~0 -> ~0.5 (neutral)

6. agent_opener_override——辅助信号

metadata->>'agent' 是从 system prompt 的开场白("You are Claude Code, …")里解析出来的编码 agent 身份。它来自内容而不是请求头,很难无意中伪造出来,因此可以当作真人的有力证据。只有这个开场白出现在 ≥ 5% 的请求里时,这个信号才可用,因此它只能把评分拉向真人;没有它也说明不了什么:

sub = clamp01(0.15 - agent_share)     # opener pervasive -> ~0 (human)

它还会触发组合步骤里的真人硬封顶(hard human clamp)。

7. user_message_shape——用户消息长度与熵

prompt_size_dispersion 看的是整个输入,这个信号看的则是用户自己写的消息。写日志时,会为每个请求里最新一条 user 角色消息预先算好三个属性,存成几个开销很小的列(在 user_message_stats 里,与 conversation_shape 并列):字符长度、香农字符熵(bit/字符),以及去掉首尾空白后文本的稳定 64 位哈希。这样算评分时就不必对 prompt 做 de-TOAST。三个分项融合时,只在达到数据下限的分项之间重新归一化,每一项都是 HIGH = 自动化:

分项

权重

公式

数据下限

长度离散度

0.40

clamp01(1 - size_rcv / 0.5),其中 size_rcv = (p75 - p25)/median,按 last_user_msg_chars 计算

≥ 8 sized msgs

单条消息熵

0.25

clamp01(1 - mean_entropy / 4.0)(自然语言 ≈ 4 bit/字符)

≥ 5 msgs

跨消息重复度

0.35

clamp01((1 - distinct_ratio) / 0.5), distinct_ratio = distinct(hash)/count

≥ 8 hashed msgs

模板化的自动化流量,用户消息长度几乎不变(长度离散度低),内容是低熵的结构化载荷,而且同一条消息会一遍遍重发(去重比例低 → 重复度高);真人交互时这三项都会变化。这些列只对新流量写入,所以如果一个用户的请求全都早于这次迁移,这个信号就直接不可用(被丢弃)。

由于它只衡量用户自己写的消息,脚本要想在这一项上显得像人,唯一的办法就是真的让发送的内容有变化。

信号的组合

A          = signals available for this user
weight_sum = sum(weight_i for i in A)
raw        = sum(sub_i * weight_i for i in A) / weight_sum      # re-normalized blend

# Hard human clamp: a high-volume coding-agent user with a real nightly rest gap
# can never be branded above "mixed" on volume alone.
if agent_share >= 0.3 and rest_gap_part_available and rest_gap_score < 0.5:
    raw = min(raw, 0.5)

# Confidence shrinkage toward the neutral 0.5 prior for low-volume users.
alpha = N / (N + 30)
score = clamp01(alpha * raw + (1 - alpha) * 0.5)

# coverage = available weight / total signal weight, so confidence stays in [0,1].
confidence = alpha * (weight_sum / TOTAL_WEIGHT)
  • 重新归一化(raw)让融合结果只取决于确实有数据的信号。所以一个只调 embedding 的批量用户,即使 num_*_turns 列全是 NULL,仍然可以根据 user-agent 和日活动形态打分。

  • 收缩(alpha = N / (N + 30))会把流量很少的用户往中性先验 0.5 拉。N = 30 时(alpha = 0.5),数据和先验各占一半;请求再多,数据就占上风。N = 5 时 alpha ≈ 0.14(评分约有 86% 被拉向 0.5)。

  • confidence 综合了请求量(alpha)和实际可用的信号权重占总权重的比例(weight_sum / TOTAL_WEIGHT),所以数据稀疏的结论,以及缺少较新信号的用户,置信度都会更低。

  • 请求数 N < 5 的用户会被标记为 insufficient_data。

档位

最终评分会映射到一个档位标签(仅供参考,务必结合 confidence 一起看):

每档包含下界、不包含上界(SCORE_BANDS 从上往下扫描,条件是 score >= lower),所以 0.35、0.60 和 0.80 都归入分数更高的那一档:

档位

范围

likely_human

0.00 ≤ s < 0.35

mixed_or_uncertain

0.35 ≤ s < 0.60

likely_automated

0.60 ≤ s < 0.80

scripted_batch

0.80 ≤ s ≤ 1.00

注意事项

  • 这只是启发式方法。没有哪个信号能单独下结论;每个信号都可能被单独伪造,所以 user-agent 的权重很低,以行为类信号为主。把评分当作初筛线索,而不是证据。

  • 时区。小时按 UTC 分桶;设计上主要依靠与时区无关的日活动分项,所以不在 UTC 时区的真人不会被误判为夜间活跃;但真正跨多个时区使用的账号,确实会抬高小时覆盖率。

  • 共享账号 / 角色账号。共享账号或团队账号会把真人流量和脚本流量混在一起,得到居中的「mixed」分数(这是符合预期的)。internal / admin 账号跑自动化本来就可能是正当的,而评分不区分角色,所以不要对它们做任何自动处置,看评分时也要考虑它们的角色。

用 CLI 复现

# rank the most script-like users in the last 30 days
python ops/db/analysis/user_automation_score.py --min-requests 20

# full per-signal breakdown for one user
python ops/db/analysis/user_automation_score.py --email [email protected]

代码位置

层

位置

核心评分 + 聚合 SQL

apps/backend/serving/analytics/automation_score.py

Store 方法

LogStore.get_user_automation_score / get_bulk_user_automation_scores (apps/backend/serving/storage/postgres_log.py)

管理端点

GET /admin/users/{user_id}/automation-score, GET /admin/users/automation-scores (apps/backend/serving/servers/routers/admin/users.py)

管理仪表盘

Users 标签页里的单用户按钮 + 批量「Automation」列

命令行工具

ops/db/analysis/user_automation_score.py

评分方法和按用户聚合指标的 SQL 都放在同一个模块里(serving.analytics.automation_score),管理端点和 CLI 共用它,所以两边的算法永远保持一致。纯评分函数不依赖数据库,单元测试在 tests/unit/test_user_automation_score.py。