最近,AI 社区又掀起了一轮熟悉的大讨论:模型是不是变笨了?你今天问它一个昨天还能答对的问题,它突然开始一本正经地胡说八道;你让它修一个正则,它分析了半天却给出一个更离谱的版本;有些时候甚至连 API 都连不上,屏幕上只留下 "model is unavailable"、"selected model is at capacity" 这类令人沮丧的提示。
从 2023 年大模型开始大规模落地到现在,“模型悄悄变笨”的质疑每隔一段时间就会出现一次,模型厂商也不止一次公开回应过类似论调。但问题一直悬而未决:我们到底该信个人体感,还是信官方 benchmark?能不能把“大家觉得变笨了”这件事变成可量化、可追溯、可比较的指标?
Hacker News 上最近出现了一个 "Show HN" 项目,标题非常直接:Is AI Dumber Today? An index of AI model experience from user's opinion。它想做一件很多评测榜单没做过的事:不从跑分出发,而从用户的真实使用反馈出发,建立一个“AI 模型体验指数”。
我的判断是:这类项目表面像是“AI 吐槽收集器”,本质却是一个值得拆解的大模型应用工程样例。它背后包括数据采集、文本解析、LLM 观点抽取、模型名归一化、时间窗口聚合、Web 可视化这一整套链路。无论你是做模型评测、开发者工具、AI Agent 还是模型服务质量监控,这篇文章都会给你一个完整的工程视角。我会从项目要解决的问题讲起,然后给出一个最小可运行的实现,你可以照着自己搭一个“AI 体感指数”。
1. 为什么“AI 变笨了”会被反复讨论,却一直说不清楚
用户抱怨模型变弱,厂商展示的 benchmark 分数却普遍上涨,这种割裂在过去两年里反复出现。要理解体验指数的价值,先得理解这种割裂是怎么产生的。
1.1 个人体感的问题:样本太小,噪声太大
单个用户的“变笨”判断,本质上是一个小样本事件。你今天觉得模型写代码变差了,可能只是因为这个问题恰好超出了当前模型擅长的分布;可能因为你把 prompt 写得更长更复杂;也可能因为后端起的是小模型而不是主模型。
在大模型的实际工程链路里,“变笨”的体感往往来自以下几类问题:
- 服务端为控制成本做容量调度,高峰期部分请求被路由到更小、更快的模型;
- 上下文窗口被塞得太满,模型在超长上下文里丢失了早前指令;
- 模型供应商悄悄切换了版本、量化精度或推理参数,同一个模型名背后已经换了内核;
- 客户端配置错误,比如 model 配置项缺失、模型名不被当前客户端识别;
- 单纯的服务不稳定,API 返回 429、超时、capacity 错误。
第一种是模型真实能力波动,最后几种其实是工程故障或配置故障。普通用户分不清这些层次,只会简单归类成“AI 变笨了”。所以,个人体感是一条很重要的信号,但它不能直接作为结论使用。
1.2 官方 Benchmark 的问题:离真实使用场景太远
官方 benchmark 的问题正好相反:它足够标准化,但离用户真实任务太远。一个能在 MMLU、HumanEval 上拿到高分的模型,在实际使用中仍可能因为指令跟随不稳定、输出过长、拒绝回答、工具调用格式错误等原因让用户崩溃。Benchmark 的数据集是静态的,模型厂商理论上可以针对性地优化;而用户每天提交的真实任务分布是动态的、长尾的,甚至包含大量 benchmark 里完全不会出现的噪音输入。
这两种数据其实互补。Benchmark 回答“模型在最优化条件下的潜力有多大”,用户观点回答“模型在真实场景下让我满意吗”。过去大家过度关注前者,反而忽视了后者。一个把用户观点工程化的体验指数,就是想补上这块缺失。
1.3 指数要做的,是把“情绪”变成“时间序列”
做体验指数的人真正要处理的不是“AI 到底笨没笨”,而是“用户的负面情绪在什么时间、针对哪个模型、以什么强度出现”。如果能把吐槽文本变成带有模型名、时间、情绪分数的结构化记录,再去按天聚合,就能得到一条情绪时间序列。这条序列不能直接证明模型能力下降,却能告诉我们:用户体感是否在恶化、恶化集中在哪个模型、恶化从哪个版本周期开始。这对于模型运营方、Agent 开发者和工具链维护者来说,都是高价值信号。
2. “AI 模型体验指数”是什么:概念、口径与边界
2.1 从“指数”这个概念说起
指数是把多个样本压缩成一个可比较数值的统计工具。常见的有 NPS(净推荐值)、消费者信心指数,技术圈里也有 TIOBE、Google Trends 等。AI 模型体验指数,就是把用户在社区中关于某个模型的反馈文本,压缩成一个能反映“近期使用体验好坏”的数值。
从项目标题看,它是一个由用户观点驱动的模型体验索引。这意味着它的数据来源不是内部日志,而是公开的、用户主动发布的内容。这类数据的优点在于真实、多样,缺点在于稀疏、不均衡、噪声大。Alpha 版本通常只做一件事:让你看到某个模型在过去一周、一个月里的用户反馈走向。
2.2 一种可执行的口径定义
为了让讨论落地,我在这篇文章里给出一个最小可用口径。假设我们把一条用户帖子的“负面强度”定义为 0 到 100 的分数,那么一个模型在某天的体验指数可以这样计算:
Score(post) = 模型 m 在帖子 p 中的负面情绪强度,0 表示完全不负面,100 表示极其负面 Index(m, date) = AVG(Score(post)) over posts mention m in recent 7 days neg_ratio(m, date) = 负面帖子数 / 提及该模型的帖子总数这里的 Score 可以由 LLM 抽取,也可以由规则模型给出。指数不追求单条文本的绝对正确,而追求聚合结果在时间维度上的稳定性。换句话说,单条情绪判断错了可以接受,只要系统性偏差不大,趋势信号仍然可信。
2.3 与 Benchmark 的对比
| 维度 | Benchmark | 用户观点体验指数 |
|---|---|---|
| 数据来源 | 人工构造的标准测试题 | 用户在社区中的真实吐槽与好评 |
| 回答的问题 | 模型能力上限有多高 | 用户最近觉得模型好不好用 |
| 更新频率 | 依赖评测集发布周期 | 可以实时滚动更新 |
| 可解释性 | 分数和榜单,便于横向比较 | 需要看原帖上下文才能定位问题 |
| 主要风险 | 可能过拟合、离真实场景远 | 噪声大、样本偏差大、不能代表能力 |
| 更适合谁 | 模型研发、学术评测 | 应用开发者、运维、产品经理、Agent 工具链团队 |
2.4 边界:这是体验指标,不是能力指标
体验指数最大的边界在于:用户口碑差,不代表模型能力差。负面反馈上升可能是公共事件导致的情绪集中爆发,比如一次糟糕的产品改版、一次模型下线、一个流传很广的“翻车视频”;也可能是某个社区的用户本来就不喜欢某个厂商。所以,做指数时不能把“用户不满意”直接等同于“模型退化”,而是要把数据当成一个“待解释的信号”,而不是“已证明的事实”。
3. 体验指数的系统设计与数据采集方案
3.1 整体分层架构
从工程角度看,这个项目可以拆成五层。下面用一个文字版的流程描述,便于你对照实现:
采集层 -> 清洗层 -> 抽取层 -> 聚合层 -> 展示层 采集层:获取各平台公开帖子或评论区内容 清洗层:去 HTML、去重复、过滤无关讨论 抽取层:识别“提到哪个模型 + 什么时间 + 态度倾向 + 负面强度” 聚合层:按 model + date 聚合,计算平均分、负面占比、样本量 展示层:Web 页面或 API,输出趋势曲线和原始证据链接这个架构里最容易低估的是清洗层和归一化层。很多初学者会急着训练情绪分类器,结果最后发现连“模型名没统一”“同一个帖子抓了三次”“时间字段有时区差异”都没解决。数据没有归一化之前,一切高级算法都是空中楼阁。
3.2 数据来源与抓取方式
要做体验指数,第一步是决定从哪些平台采集。不同平台的开放程度差异很大:
- Hacker News 提供官方 Algolia Search API,支持按时间、关键词查询,适合做英文技术社区反馈;
- Reddit 有 JSON 接口,但风控和速率限制比较严格,需要遵守平台条款;
- X / Twitter 的采集成本高,通常需要付费 API;
- 国内平台各有不同的开放策略,抓取前务必阅读 robots 协议和相关规定。
更稳妥的做法是:从产品早期就设计“导入接口”,允许通过 CSV/JSON 导入人工收集的帖子,再逐步接入官方 API。不要在一开始就把时间浪费在对抗反爬上,这既有合规风险,也不是项目的核心价值。
3.3 清洗与字段设计
抓下来的原始数据五花八门。为了让后续的抽取层稳定工作,清洗层应该统一输出这样的结构:
{ "post_id": "hn_123456", "source": "hackernews", "content": "最近用 gpt-4o 写 SQL,连续三次 JOIN 都写错了,感觉变笨了", "published_at": "2025-03-02T10:15:00Z", "fetched_at": "2025-03-02T12:00:00Z", "url": "https://news.ycombinator.com/item?id=123456" }字段越早统一,后续模型名识别、情绪抽取、指数聚合就越省力。真正做过这类项目的人会告诉你:后面 80% 的调试时间都花在字段不规范上,而不是算法上。
4. 环境准备与工程目录
为了让你能直接跑通一个最小原型,我用 Python 作为主语言,技术栈选择尽量轻量。
4.1 技术栈与版本建议
- Python 3.10 及以上;
- FastAPI + Uvicorn:提供指数查询 API;
- Pydantic:做数据校验;
- openai SDK:作为兼容接口客户端,连接 OpenAI 兼容的模型服务;
- SQLite:本地存储,零部署成本;
- httpx 或 requests:采集层预留。
以下版本并非限定,重点是演示通用实现思路。
4.2 工程目录结构
ai-model-experience-index/ ├── .env.example ├── requirements.txt ├── data/ │ └── sample_posts.json └── indexer/ ├── __init__.py ├── models.py ├── extract.py ├── aggregate.py ├── cli.py └── api.py4.3 依赖清单
# requirements.txt fastapi==0.115.0 uvicorn==0.30.0 pydantic==2.7.0 openai==1.40.0 python-dotenv==1.0.1 httpx==0.27.0# .env.example LLM_API_KEY=your_api_key_here LLM_BASE_URL=https://api.openai.com/v1 LLM_MODEL=gpt-4o-mini DB_PATH=index.db配置环境变量是为了不把密钥写死在代码里。在真实项目中,密钥管理应该接入专门的配置中心或密钥管理服务,而不是只靠 .env。
5. 完整示例:从用户输入到指数 API
下面是一个最小可运行实现。我把它拆成 4 个代码模块,每个模块只做一件事。文章里不会真的请求外部模型,样例数据也是本地模拟,所以你可以直接复制运行。
5.1 定义数据模型
这一层用 Pydantic 定义采集层的统一结构和抽取结果结构。
# indexer/models.py from datetime import datetime from typing import List, Optional from pydantic import BaseModel class RawPost(BaseModel): """清洗后的一条原始帖子。""" post_id: str source: str content: str published_at: datetime fetched_at: datetime url: str = "" class ExtractedOpinion(BaseModel): """从帖子中抽取出的观点记录。""" post_id: str model: str negative_score: int # 0-100,越高表示负面体验越强 sentiment: str # positive / neutral / negative reason: str # LLM 给出的简短解释 evidence: str = "" # 保留可溯源的原始关键句需要说明的是,RawPost 已经假定完成了 HTML 清洗和字段抽取。实际采集阶段要把网页正文、标题、评论作者等非结构化内容去掉,这一层不在本文展开。
5.2 观点抽取:优先用 LLM,失败时用规则兜底
这是全链路的核心。给定一条用户帖子,我们要判断它是否在吐槽某个模型、吐槽的强度有多大。
# indexer/extract.py import json import os import re from typing import List from dotenv import load_dotenv from openai import OpenAI from indexer.models import ExtractedOpinion, RawPost load_dotenv() LLM_API_KEY = os.getenv("LLM_API_KEY", "") LLM_BASE_URL = os.getenv("LLM_BASE_URL", "https://api.openai.com/v1") LLM_MODEL = os.getenv("LLM_MODEL", "gpt-4o-mini") MODEL_ALIASES = { "gpt4o": "gpt-4o", "gpt-4.o": "gpt-4o", "gpt4": "gpt-4", "claude3.5": "claude-3.5-sonnet", "claude sonnet": "claude-3.5-sonnet", "deepseek": "deepseek-chat", "ds": "deepseek-chat", } NEGATIVE_WORDS = [ "变笨", "变傻", "退步", "变慢", "答非所问", "幻觉", "错误", "bad", "worse", "dumber", "broken", "slow", "useless", "fail" ] def normalize_model_name(raw: str) -> str: """模型名归一化:先小写去空格,再做常见别名映射。""" if not raw: return "unknown" key = raw.strip().lower().replace(" ", "") return MODEL_ALIASES.get(key, raw.strip().lower()) def _strip_code_fence(text: str) -> str: text = text.strip() if text.startswith("```"): lines = text.splitlines() lines = lines[1:] if lines and lines[-1].strip().startswith("```"): lines = lines[:-1] text = "\n".join(lines).strip() return text def extract_with_llm(post: RawPost) -> ExtractedOpinion: prompt = f""" 你是 AI 模型体验分析师。请阅读下面的用户帖子,完成两个任务: 1. 判断帖子在讨论哪个 AI 模型(模型名用规范英文名,例如 gpt-4o、claude-3.5-sonnet)。 2. 判断用户对该模型体验的负面强度,negative_score 取值范围 0-100。 - 0 表示完全正面或中性 - 40-60 表示轻度不满/犹豫 - 70 以上表示强烈负面,比如认为模型明显变笨、不可用、反复出错 只输出 JSON,不要输出解释。 帖子内容: {post.content} 输出 JSON 格式: {{"model": "模型名", "sentiment": "negative/neutral/positive", "negative_score": 整数, "reason": "一句话解释", "evidence": "原文中的关键句"}} """ client = OpenAI(api_key=LLM_API_KEY, base_url=LLM_BASE_URL) resp = client.chat.completions.create( model=LLM_MODEL, messages=[{"role": "user", "content": prompt}], temperature=0, ) content = _strip_code_fence(resp.choices[0].message.content or "{}") data = json.loads(content) return ExtractedOpinion( post_id=post.post_id, model=normalize_model_name(data.get("model", "unknown")), negative_score=int(data.get("negative_score", 50)), sentiment=data.get("sentiment", "neutral"), reason=data.get("reason", ""), evidence=data.get("evidence", post.content[:200]), ) def extract_with_rules(post: RawPost) -> ExtractedOpinion: """规则兜底:识别模型别名和负面词,用于离线演示和降级。""" text = post.content.lower() hit_model = "unknown" for alias, canonical in MODEL_ALIASES.items(): if alias in text: hit_model = canonical break if hit_model == "unknown": for pattern in [r"gpt-?4o?", r"claude[ -]?[\d.]*", r"deepseek"]: m = re.search(pattern, text) if m: hit_model = m.group(0) break hit_model = normalize_model_name(hit_model) hit_words = [w for w in NEGATIVE_WORDS if w.lower() in text] if not hit_words: return ExtractedOpinion( post_id=post.post_id, model=hit_model, negative_score=20, sentiment="positive", reason="规则兜底:未发现明显负面词", evidence=post.content[:200], ) score = min(90, 40 + 10 * len(hit_words)) return ExtractedOpinion( post_id=post.post_id, model=hit_model, negative_score=score, sentiment="negative", reason="规则兜底:命中负面词 {}".format(",".join(hit_words)), evidence=post.content[:200], ) def extract_opinion(post: RawPost, use_llm: bool = True) -> ExtractedOpinion: """优先使用 LLM 抽取;若没有 API Key 或解析失败,则走规则兜底。""" if use_llm and LLM_API_KEY and LLM_API_KEY != "your_api_key_here": try: return extract_with_llm(post) except Exception as exc: # noqa: BLE001 print(f"LLM 抽取失败,切换到规则兜底:{exc}") return extract_with_rules(post)这里的规则兜底不是工程上的最佳方案,但它保证了教程在没有外部 API 的情况下可以完整跑通。真实项目里,我建议保留这个 fallback 作为流控和降级策略,避免模型服务一抖动,整个数据管道就停摆。
5.3 指数聚合
抽取完成后,每条帖子会变成带模型名和时间戳的结构化记录。接下来把它写入 SQLite 并聚合。
# indexer/aggregate.py import sqlite3 from datetime import datetime from indexer.models import ExtractedOpinion, RawPost CREATE_TABLE_SQL = """ CREATE TABLE IF NOT EXISTS extracted_posts ( post_id TEXT PRIMARY KEY, model TEXT, published_at TEXT, negative_score INTEGER, sentiment TEXT, reason TEXT, evidence TEXT, fetched_at TEXT ); """ CREATE_INDEX_SQL = """ CREATE INDEX IF NOT EXISTS idx_model_time ON extracted_posts(model, published_at); """ def init_db(db_path: str) -> None: conn = sqlite3.connect(db_path) conn.execute(CREATE_TABLE_SQL) conn.execute(CREATE_INDEX_SQL) conn.commit() conn.close() def upsert_opinions(db_path: str, opinions: list[ExtractedOpinion]) -> int: """写入抽取结果,重复 post_id 直接覆盖。""" conn = sqlite3.connect(db_path) count = 0 for op in opinions: conn.execute( """ INSERT INTO extracted_posts (post_id, model, published_at, negative_score, sentiment, reason, evidence, fetched_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(post_id) DO UPDATE SET model=excluded.model, negative_score=excluded.negative_score, sentiment=excluded.sentiment, reason=excluded.reason, evidence=excluded.evidence """, ( op.post_id, op.model, op.published_at.isoformat(), op.negative_score, op.sentiment, op.reason, op.evidence, datetime.utcnow().isoformat(), ), ) count += 1 conn.commit() conn.close() return count def compute_daily_index(db_path: str, window_days: int = 7) -> list[dict]: """按 model + 天 聚合,只统计最近 window_days 天内的记录。""" conn = sqlite3.connect(db_path) conn.row_factory = sqlite3.Row rows = conn.execute( """ SELECT model, substr(published_at, 1, 10) AS day, COUNT(*) AS sample_count, ROUND(AVG(negative_score), 2) AS avg_negative_score, ROUND( SUM(CASE WHEN negative_score >= 60 THEN 1 ELSE 0 END) * 1.0 / COUNT(*), 2 ) AS neg_ratio, ROUND( SUM(CASE WHEN sentiment = 'positive' THEN 1 ELSE 0 END) * 1.0 / COUNT(*), 2 ) AS pos_ratio FROM extracted_posts WHERE published_at >= datetime('now', ?) GROUP BY model, day ORDER BY avg_negative_score DESC """, (f"-{window_days} days",), ).fetchall() conn.close() return [dict(row) for row in rows]注意,SQL 里的datetime('now', ?)是按 SQLite 本地时间换算的。真实项目中,时间字段应该统一成 UTC 并记录时区,避免各国用户发帖时间带来聚合误差。
5.4 命令行入口
# indexer/cli.py import json import sys from pathlib import Path from indexer.aggregate import compute_daily_index, init_db, upsert_opinions from indexer.extract import extract_opinion from indexer.models import RawPost def load_posts(path: str) -> list[RawPost]: with open(path, "r", encoding="utf-8") as fp: data = json.load(fp) return [RawPost(**item) for item in data] def main() -> None: if len(sys.argv) < 2: print("用法: python -m indexer.cli ingest <data.json> [--aggregate]") return command = sys.argv[1] if command == "ingest": posts_path = sys.argv[2] posts = load_posts(posts_path) print(f"读取到 {len(posts)} 条帖子") init_db("index.db") opinions = [extract_opinion(p, use_llm=False) for p in posts] saved = upsert_opinions("index.db", opinions) print(f"写入 {saved} 条观点记录") if command == "aggregate": init_db("index.db") rows = compute_daily_index("index.db", window_days=30) for row in rows: print(row) if __name__ == "__main__": main()5.5 FastAPI 查询接口
# indexer/api.py from fastapi import FastAPI, Query from indexer.aggregate import compute_daily_index, init_db app = FastAPI(title="AI Model Experience Index API") @app.on_event("startup") def on_startup() -> None: init_db("index.db") @app.get("/index") def get_index(model: str = Query("", description="模型名,不传则返回全部")): rows = compute_daily_index("index.db", window_days=30) if model: rows = [r for r in rows if r["model"] == model.lower().strip()] return { "model": model or "all", "window_days": 30, "data": rows, "message": "体验指数只反映用户观点聚合,不直接代表模型真实能力" }从这一段你能看出,整条数据链路非常短:RawPost -> ExtractedOpinion -> SQLite -> API。真实系统比这要重,但核心链路并没有本质区别。
5.6 演示用样例数据
为了不依赖外部采集,我准备了一份本地样例数据。它模拟了清洗层输出的格式。
// data/sample_posts.json [ { "post_id": "demo_001", "source": "demo", "content": "最近让 gpt-4o 写一个 Python 装饰器,结果参数名写错了三次,感觉它真的变笨了。", "published_at": "2025-03-01T10:00:00Z", "fetched_at": "2025-03-01T11:00:00Z", "url": "https://example.com/demo/001" }, { "post_id": "demo_002", "source": "demo", "content": "deepseek-chat 今天回答数学题明显变慢了,而且逻辑错误比上周多。", "published_at": "2025-03-01T12:00:00Z", "fetched_at": "2025-03-01T13:00:00Z", "url": "https://example.com/demo/002" }, { "post_id": "demo_003", "source": "demo", "content": "Claude 3.5 Sonnet 写重构代码还是很稳,我挺满意的。", "published_at": "2025-03-02T08:00:00Z", "fetched_at": "2025-03-02T09:00:00Z", "url": "https://example.com/demo/003" }, { "post_id": "demo_004", "source": "demo", "content": "gpt-4o 在长文档摘要时又开始幻觉了,越长的输入越不靠谱。", "published_at": "2025-03-02T09:30:00Z", "fetched_at": "2025-03-02T10:00:00Z", "url": "https://example.com/demo/004" } ]这里的链接是演示用的占位地址,不构成真实数据来源。你在实际项目中,替换成自己采集到的合法公开内容即可。
6. 运行结果与效果验证
6.1 启动方式
先把工程文件按上面的结构放好,然后执行:
pip install -r requirements.txt python -m indexer.cli ingest data/sample_posts.json python -m indexer.cli aggregate因为没有配置有效的 LLM API Key,抽取层会自动走规则兜底,所以整个过程不需要外部网络。
预期输出大致如下:
读取到 4 条帖子 写入 4 条观点记录 {'model': 'deepseek-chat', 'day': '2025-03-01', 'sample_count': 1, 'avg_negative_score': 80.0, 'neg_ratio': 1.0, 'pos_ratio': 0.0} {'model': 'gpt-4o', 'day': '2025-03-01', 'sample_count': 1, 'avg_negative_score': 80.0, 'neg_ratio': 1.0, 'pos_ratio': 0.0} {'model': 'gpt-4o', 'day': '2025-03-02', 'sample_count': 1, 'avg_negative_score': 80.0, 'neg_ratio': 1.0, 'pos_ratio': 0.0} {'model': 'claude-3.5-sonnet', 'day': '2025-03-02', 'sample_count': 1, 'avg_negative_score': 20.0, 'neg_ratio': 0.0, 'pos_ratio': 1.0}如果看到这些数据,说明链路已经走通。你也可以再启动 API 服务:
uvicorn indexer.api:app --reload --port 8000然后访问:
curl "http://127.0.0.1:8000/index?model=gpt-4o"6.2 怎样判断指数是可信的
“能跑通”和“结果可信”是两回事。指数系统的可信度可以从三件事上验证:
- 抽取一致性:随机抽 50 条帖子,让两个人手工判断模型名和情绪,再和 LLM 抽取结果对比;
- 时间稳定性:同一个模型的数据,如果前后两天指数突然从 30 跳到 90,应该能定位到具体帖子;
- 样本量可见性:低样本量时的指数起伏没有统计意义,展示时必须同时呈现 sample_count,否则很容易误导。
真正上线时,更推荐做一份“被纳入样本的帖子清单”,点击指数曲线上的某个点,可以下钻到具体原帖。没有原始证据支撑的指数,本质上只是另一个“感觉”。
7. 数据质量与工程陷阱
做体验指数这类项目,最终会发现难点不在模型能力,而在数据质量。下面几个问题,几乎每个做舆情聚合项目的人都会遇到。
7.1 模型名归一化是最容易被低估的问题
用户不会规范地写模型名。你会发现同一条模型有几十种写法:gpt4o、GPT-4.o、gpt-4o(2024-11-20)、gpt-4o latest、new gpt。如果不在抽取层做别名映射,聚合结果会被拆得七零八落。我在代码里用了一个简单的字典做映射,真实项目里这一块会演变成规则引擎 + 定期人工维护的版本知识库。要注意,同一个模型名在不同时间可能指向不同版本快照,所以最好在字段里保留model_version或model_snapshot。
7.2 时间字段不一致会让时间序列失真
每条帖子的时间可能是用户发帖时间、采集时间、页面显示时间。如果你的系统不统一时区,聚合出来的“每日指数”会带有很大的随机偏移。推荐的规范是:
- 存储层统一用 UTC ISO8601 字符串;
- 展示层再按读者本地时区换算;
- 分析滚动窗口时,以发帖时间
published_at为准,而不是fetched_at。
如果错误使用采集时间,一次爬虫半夜重跑会把大量旧帖的“发布时间”错标到当天,导致指数曲线出现假性尖峰。
7.3 重复帖子会造成虚假信号
同一条热门吐槽往往会在多个平台被转载,甚至同一个平台也会重复出现。如果不做内容去重,一条极端负面帖子可能贡献几十个样本量,把指数拉离真实水平。去重思路通常有两种:
- 基于
post_id的精确去重,适合同一平台; - 基于标题/正文 SimHash 或 MinHash 的近似去重,适合跨平台转载。
最小原型里我用了post_id作为主键。真实项目中,建议再加上正文 Hash 字段,用来快速发现重复内容。
7.4 情绪打分会有系统性偏差
规则兜底算法非常简单,它倾向于把包含负面词的帖子打高分,却没有理解上下文。例如“它没有以前那么笨了”会被规则判成负面,而 LLM 能理解这是褒义。长期运行的系统必须避免单一打分模型,而是定期抽样,用自己的标注集来校准。情绪打分只要保持系统性偏差相对稳定,趋势对比还有意义;但如果今天用一个规则、明天用一个新模型,没有校准就直接切换,指标的前后可比性就断了。
7.5 不要把“基础设施故障”当成“模型能力下降”
做指数系统记录数据时,要区分用户