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

自动化评分0.01.0 区间上刻画每个用户的 API 流量有多少是脚本驱动的、有多少是人类驱动的:

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

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

它是用于分诊的启发式判断,不是定论。务必结合返回的 confidence 和逐信号明细一起看。

备注

该功能会刻画单个用户的流量特征。计算一次评分要读取该用户在窗口内的 api_logs 行——他们何时发出请求、prompt 有多长、用的是哪个客户端,以及对其最新一条用户消息预先算好的形态统计(长度、字符熵和一个哈希——见信号 7)。它只能从下文的管理端点和运维 CLI 触达,用户自己无法访问,任何公开路由也无法访问。在真实流量上启用它的 deployment 应当在自己的隐私政策里说明这一点,并且应把评分当作人工决策的输入,而不是自动处置的依据。

代码位置

位置

核心评分 + 聚合 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」列

CLI

ops/db/analysis/user_automation_score.py

评分方法和聚合每用户指标的 SQL 都在同一个模块里(serving.analytics.automation_score),由管理端点和 CLI 共用,因此两者绝不会漂移。纯评分函数不依赖数据库,单元测试在 tests/unit/test_user_automation_score.py

输入

评分在一个回溯窗口内计算(默认 30 天;管理端点接受 1..90 之间的 days)。只统计 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);

  • 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 是后来以 0.15 的权重加入的,并没有改动原有的六个,所以缺少(较新的)用户消息列的用户——例如迁移之前记录下来的流量——会丢弃它,得到与之前相同的融合评分,只是 confidence 略低一点。

1. turn_pattern——用户轮次

交互式会话每次都会重发不断增长的历史,所以 num_user_turns1, 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)

depth_factor 会把子分减半,条件是该用户确实有过深度(p90 3)多轮会话,这样一个同时也发大量一次性请求的人不会被判成脚本。

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 的 Python 移植),每个类别对应一个每请求的自动化取值:

客户端类别

示例

取值

交互式 / 编码 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 这一项让编码 agent 开场白(见下文)即便藏在一个很像脚本的 UA 背后,也能把先验拉向人类。

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 是不活跃钟点的最长环形连续段——人类夜间睡眠形成的间隙会把休息间隙这一分项压向 0,而 7×24 运行则完全没有间隙(→ 1)。与时区无关的几个分项(规律性、休息间隙、熵)占了大部分权重,所以用户时区未知只会平移直方图,不会扭曲结论(小时按 UTC 分桶)。

5. tool_call_human_tell——辅助信号

Agentic 的工具调用(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)

它同时驱动组合步骤里的人类硬钳制

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

模板化的自动化发出的用户消息长度几乎恒定(长度离散度低)、载荷是低熵的结构化内容,并且一遍遍重发同一条消息(去重比例低 → 重复度高);交互式的人类三者都在变。由于这些列只对新流量填充,对于请求全部早于该次迁移的用户,这个信号直接不可用(被丢弃)。

这个信号把用户输入单独隔离出来,再配合 metadata->>'agent' 里的元数据和逐消息哈希,不真正改变内容就很难伪造。

信号的组合

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 = 5alpha 0.14(评分被拉向 0.5 约 86%)。

  • confidence 把请求量(alpha)与实际可用的信号权重占比(weight_sum / TOTAL_WEIGHT)结合起来,所以稀疏的结论——以及缺少较新信号的用户——读出来置信度更低。

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

档位

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

下界闭、上界开(SCORE_BANDS 自上而下扫描,判据是 score >= lower),所以 0.350.600.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 user@example.com