AI 回答的来源列表解析,指的是把一条回答末尾给出的引用清单,拆成引用位置、链接、域名三层,再落成可复算记录的过程。
本文的数字来自笔者所在团队自建的一套 AI 可见度监测库,项目 56,不对应任何对外产品。
结论先放在这里:带被引链接的 815 条记录、按链接去重后的 273 个网页、按域名归并后的 140 个站点,是三个不能互相替代的数(数据源:监测库 citations 表 · 查询日 2026-10-18 · 样本:8959 条抓取记录里带 URL 的 815 条 · 口径:只统计回答里给出网址的引用)。同一批原始记录,换个分母就能拼出另一个结论,所以我把三层分开存、分开报。
问:为什么不能直接数「同一条链接被 AI 引了几次」? **答:**一次回答可以带多个来源,同一条链接又会在不同回答里重复出现。你要的分母是回答数、记录数还是网页数,得先定下来再动手数。
问:三层里最难做的是哪一层? **答:**引用位置。链接和域名都有成熟的字符串处理办法,位置只能靠解析器把来源段切对,切错一次,后面所有跟位置有关的判断都跟着错。
问:域名归并之后能回答什么? **答:**只能回答「哪个站点被引得多」。它不回答「哪篇文章被引得多」,也不回答「哪一家机构被提到」。
来源列表不是一份现成的数据
把一条回答存下来之后,真正难处理的是它的来源部分。我见过的形态至少有四种:有序编号列表、无序短横线列表、带 Markdown 链接的列表、以及只写域名不带协议头的纯文本。有的平台把来源收在正文末尾,前面挂一个「参考来源」或「出处」的标签;有的平台不给独立来源段,只在正文句子里内联标注;还有的平台两者都有,内联一次、末尾再列一次。
这些形态的差别看起来只是排版,实际决定了解析器从哪里开始切。我第一版的做法是对整条回答跑一遍网址正则,把找到的地址都当引用。结果导出的来源条目明显偏多,多出来的那部分几乎都是正文里举例子用的网址,或者回答里引用的第三方页面。分母一脏,后面算出来的任何比例都不可信。
现在的做法是先切段、再逐条取。切段的规则很朴素:在回答里找最后一个来源类标记词,只处理它后面的内容。这样正文里出现的网址不会被误收进来源层。代价是标记词认不出来的时候整条回答的来源会被漏掉,所以我在解析记录里留了一个「来源段是否识别成功」的布尔字段,漏掉的那些可以单独捞出来补规则,而不是混进统计里。
三层各自存什么
三层不是三个步骤,而是三列同时存在的字段。同一条来源在三列上各有各的值,归并动作只作用在自己的那一列上。
| 层 | 存什么 | 例子 | 归并动作 | 归并之后会丢掉什么 |
|---|---|---|---|---|
| 引用位置 | 来源在列表里的序号,以及正文里是否内联标注过 | 第 2 条来源、只在列表出现 | 不归并,原样保留 | 不同平台的列表长度不同,序号不能跨平台直接比较 |
| URL | 原始字符串,以及归一化之后的字符串 | 带 utm_source 的原串、去掉跟踪参数后的地址 | 去协议差异、去片段、去跟踪参数、去末尾斜杠 | 参数里可能带着平台、时间、渠道这类信息 |
| 域名 | 主机名,以及归并后的站点名 | m.example.org 归到 example.org | 主机名转小写、按站点归并 | 同一域名下的不同作者、不同栏目被抹平 |
三列的用途也不一样。URL 层回答「这个页面被引了多少次」,域名层回答「哪个站点被引得多」,引用位置层回答「同一条链接通常出现在来源列表的什么位置」。这三句话听起来接近,实际是三个方向的问题,混在一起写,报告里就会出现互相打架的句子。
引用位置这一层最容易被省掉,因为它看起来没有分析价值。我的判断相反:位置是唯一能区分「主推来源」和「兜底来源」的字段。一条链接出现在来源列表第一条和出现在第十条,对写作者的含义完全不同,而在只存 URL 和域名的表里,这两种情况长得一模一样。
解析器:先切段,再逐条取三层
下面这段在 3 条构造样本上跑,输出是当次真实结果。样本的作用是让你看清解析出的三层长什么样,不是生产库的读数。
import re
ANSWERS = [
{"platform": "甲", "text": "结论略。\n\n参考来源:\n1. https://www.example.com/a/1?utm_source=chat&utm_medium=app\n2. https://news.example.net/b/2\n3. example.org/c"},
{"platform": "乙", "text": "结论略。\n\n来源:\n- https://example.com/a/1\n- https://m.example.org/c/"},
{"platform": "丙", "text": "结论略,正文里提到 example.net/b/2 但没给链接。\n\n引用:\n1) https://www.example.com/a/1?from=share&id=33"},
]
MARKER = re.compile(r"(?:参考来源|参考资料|来源|出处|引用)\s*[::]?\s*")
ITEM = re.compile(r"^(?:[-*+]|\d+[.、)])\s*(.+)$")
URL = re.compile(r"https?://[^\s))>\]]+")
def split_list(text):
"""只在最后一个来源标记之后切列表,正文里出现的网址不进来源层。"""
ms = list(MARKER.finditer(text))
if not ms:
return []
tail = text[ms[-1].end():]
out = []
for line in tail.splitlines():
m = ITEM.match(line.strip())
if m:
out.append(m.group(1).strip())
return out
def to_row(platform, pos, raw):
m = URL.search(raw)
if m:
url = m.group(0)
else:
md = re.search(r"\]\((https?://[^)]+)\)", raw)
url = md.group(1) if md else "https://" + raw.strip("[]() ")
host = re.sub(r"^(https?://)", "", url).split("/")[0].lower()
return {"platform": platform, "pos": pos, "raw": url, "host": host}
rows = []
for a in ANSWERS:
for i, raw in enumerate(split_list(a["text"]), 1):
rows.append(to_row(a["platform"], i, raw))
for r in rows:
print(f'{r["platform"]} pos={r["pos"]} host={r["host"]:<20} raw={r["raw"]}')
print(f"\n来源条目 {len(rows)} 条|涉及主机名 {len({r['host'] for r in rows})} 个")
当次输出:
甲 pos=1 host=www.example.com raw=https://www.example.com/a/1?utm_source=chat&utm_medium=app
甲 pos=2 host=news.example.net raw=https://news.example.net/b/2
甲 pos=3 host=example.org raw=https://example.org/c
乙 pos=1 host=example.com raw=https://example.com/a/1
乙 pos=2 host=m.example.org raw=https://m.example.org/c/
丙 pos=1 host=www.example.com raw=https://www.example.com/a/1?from=share&id=33
来源条目 6 条|涉及主机名 5 个
输出里有两处值得单独看。丙那条回答的正文里出现了 example.net/b/2,但它没有出现在来源列表里,所以解析结果里没有这条,这是切段规则在起作用。另一处是丙的来源序号从 1 开始,说明位置是按单条回答重新计数的,不是全局计数,跨回答的位置值不能直接相加。
解析失败的情形我也留了记录,主要有三类:来源标记词被正文里的普通用词命中、编号跨行导致列表被切断、短链没有协议头被当成纯文本。这三类目前都靠「来源段是否识别成功」这个字段兜底,漏掉的回答单独排队补规则,不混进统计。
同一链接在不同平台会长成不同样子
同一个页面被不同 AI 平台引用时,地址往往不是同一个字符串。常见差异有四类:跟踪参数(utm_*、from、spm 这类)、跳转域(平台自己的短链或中转地址)、移动版子域(m.、wap.)、以及大小写和末尾斜杠。前两类是平台加上的,后两类是站点自己就有的写法。
归一化的边界,我最后定成两条:跟踪参数剥掉,其余参数保留。原因是有些站点的内容 ID 就在参数里,?id=33 和 ?id=34 是两个不同的页面,把所有查询串一律剥掉,会把不同文章合并成一个。这一点我早期写错过:当时为了省事,? 之后全部丢弃,结果两篇不同文章的页面在表里变成了同一条,去重数偏低,而当时我没发现。
域名层的归并我放在最后做,因为它不可逆。主机名转小写、去掉 www.、按站点名归并,这三步做完,原始主机名就只留在 URL 层里了。下面这段在同一个 6 条样本上跑,把三级去重一起算出来。
import re
from collections import Counter
ROWS = [
("甲", 1, "https://www.example.com/a/1?utm_source=chat&utm_medium=app"),
("甲", 2, "https://news.example.net/b/2"),
("甲", 3, "https://example.org/c"),
("乙", 1, "https://example.com/a/1"),
("乙", 2, "https://m.example.org/c/"),
("丙", 1, "https://www.example.com/a/1?from=share&id=33"),
]
TRACK = re.compile(r"^(utm_\w+|from|share_\w+|spm|ref)$", re.I)
def normalize(url):
url = url.strip().lower()
url = re.sub(r"^http://", "https://", url)
url = url.split("#")[0]
head, _, qs = url.partition("?")
keep = [p for p in qs.split("&") if p and not TRACK.match(p.split("=")[0])]
head = re.sub(r"/index\.(html|php)$", "/", head)
head = re.sub(r"^https://www\.", "https://", head)
head = head.rstrip("/") or head
return head + ("?" + "&".join(sorted(keep)) if keep else "")
def host_of(url):
return re.sub(r"^https?://", "", url).split("/")[0]
def root_of(host):
parts = host.split(".")
return ".".join(parts[-2:]) if len(parts) >= 2 else host
norm = [normalize(r[2]) for r in ROWS]
urls = Counter(norm)
sites = Counter(root_of(host_of(u)) for u in norm)
print(f"来源条目 {len(ROWS)} 条 -> 归一后网页 {len(set(norm))} 个 -> 主机名 {len({host_of(u) for u in norm})} 个 -> 站点 {len(sites)} 个")
print("\n同一个网页被几条回答引:")
for u, n in urls.most_common():
print(f" {n} 次 {u}")
print("\n站点级被引次数(按次数降序):")
for s, n in sites.most_common():
print(f" {s:<18} {n} 条")
print("\n原始串与归一后不一致的那几条:")
for _, _, raw in ROWS:
if normalize(raw) != raw:
print(f" {raw}\n -> {normalize(raw)}")
当次输出:
来源条目 6 条 -> 归一后网页 5 个 -> 主机名 4 个 -> 站点 3 个
同一个网页被几条回答引:
2 次 https://example.com/a/1
1 次 https://news.example.net/b/2
1 次 https://example.org/c
1 次 https://m.example.org/c
1 次 https://example.com/a/1?id=33
站点级被引次数(按次数降序):
example.com 3 条
example.org 2 条
example.net 1 条
原始串与归一后不一致的那几条:
https://www.example.com/a/1?utm_source=chat&utm_medium=app
-> https://example.com/a/1
https://m.example.org/c/
-> https://m.example.org/c
https://www.example.com/a/1?from=share&id=33
-> https://example.com/a/1?id=33
注意 example.org/c 和 m.example.org/c 在网页这一层是两个,到站点这一层才合成一个。这段代码里的站点归并用的是「取最后两段」的简化写法,真实环境要接公共后缀表,否则 co.uk 这类后缀会把站点名截错。简化写法只够演示层级关系。
去重口径:815、273、140 为什么必须分开报
生产库上跑同一套逻辑,得到的就是本文开头那三个数。
本段数据口径 数据源:自建 AI 可见度监测库
citations表(项目 56)| 查询日:2026-10-18 样本:8959 条抓取记录,其中带被引链接 815 条 | 口径:只统计回答里给出 URL 的引用;链接级按归一化后地址去重,域名级按主机名归并,两级分开报、不相加 局限:单一项目的观测,不是行业抽样;仅覆盖 11 家 AI 平台且样本分布不均;库是活库,以上为 2026-10-18 快照
815 条记录落到 273 个网页,平均每个网页被引约 3.0 次。这个平均数是均摊出来的假象,真实分布非常偏:被引 6 次及以上的域名有 25 个,合计 601 次,占全部被引次数的 73.7%;只被引过 1 次的域名有 62 个。前 10 个域名合计 469 次,占 57.5%;前 30 个域名合计 623 次,占 76.4%。
这四个数放在一起才说明问题。只看域名总数 140,你会以为信源很分散;加上前 10 占 57.5% 这一条,才知道实际高度集中。反过来,只看前 10 的合计,你又不知道长尾有多长,62 个只被引一次的域名补上了这一块。
分母的坑在这几个数之间特别容易踩。815 是记录数,273 是网页数,140 是域名数,三个数的分母各不相同,任何两个相除得到的比例都要写清是拿谁做分子。我见过把 273 除以 140 当成「平均每站多少篇」的写法,那个数没有意义,因为一个网页可能被多个域名承载,一个域名也可能对应多个网页,两边不是同一个集合。
域名归并会丢掉什么
归并是为了让「同一家站点」只出现一次,代价是抹掉了域名层以下的差异。我把丢掉的东西分五类写出来,用的时候心里有数。
移动版与主站被并成一个站点。m.example.org 和 example.org 归并之后,你看不出移动入口被引了多少,而移动端来源在内容分发上的含义和主站并不一样。
同一域名下的不同作者被抹平。聚合类平台一个域名下面有大量作者页,域名层只能告诉你「这家平台被引得多」,说不出「是哪位作者的内容被引」。
跳转域会把真实落地站点盖掉。平台用短链或中转地址时,域名层统计到的是跳跃域,不是最终页面所在的站点。我的处理是先按落地页统计一次,再按原始域统计一次,两份结果都留着。
企业信息类平台一个域名下挂着很多机构页。域名层统计出「这家企业信息平台被引了很多次」,但回答里真正被提到的是哪一家机构,域名层看不出来,得回 URL 层的具体页面。
子域本身可能承载独立含义。有些站点的企业子域是单独发布的,归并到根域之后,多个客户站点挤在同一个域名里,站点层的被引次数会被放大。
一句话概括:域名层回答的是「哪个站点被引得多」,不回答「哪篇文章被引得多」,更不回答「谁被提到」。要回答后两个问题,得回 URL 层和引用位置层。
可自检清单:三步
这套流程不依赖特定工具,拿你自己的库跑一遍就能对上。
第一步,先定分母。把抓取记录总数、带被引链接的记录数、去重后的网页数、归并后的站点数四个数并排拉出来,确认哪个数配哪个比例。这一步做完,后面无论报什么,都能说清分母是谁。
第二步,切准来源段。对每条回答先找来源标记,只处理标记之后的内容,同时记录「来源段是否识别成功」。抽十条人工核对,看解析出来的条目和原回答的来源列表是否一一对应,对不上就补规则。
第三步,先归一再归并,两级各报一次。归一化只剥约定的跟踪参数,其余参数保留;域名归并放在最后,且保留原始主机名。两级数字分开报,不相加,也不互相换算。
三步的顺序不能乱:分母定了才有意义,来源段切对了数据才干净,归一在前归并在后,中间任何一步提前,后面都要返工。
边界与不承诺
所有读数都来自一套自建监测库的自身观测,不是行业抽样,也不是对任何平台的评价。样本覆盖 11 家 AI 平台,但分布不均:引用样本较多的六家是元宝 206、知乎直答 176、DeepSeek 163、智谱清言 119、豆包 83、百度AI 31(查询日 2026-10-18);其余几家样本少于 20 条,只登记、不解读。
我只统计回答里给出网址的引用。只在正文里提到站名、没有给链接的情况属于另一套口径,两组不合并、不相加。监测库是活库,同一个查询日重跑,存量和派生指标都可能变,所以跨批次比较要谨慎,我没有把不同批次的数量并列着报的习惯。
这套解析能支撑的是「哪些页面被引、被哪个平台引、在来源列表的第几位」,推不出「做了某个动作所以被引」。它也不回答曝光、流量、转化,更不回答将来会怎样。文中每个数字都带着数据源、查询日、口径和样本量,没带条件的数字我不写。真要拿它做判断,请以你自己跑出来的读数为准。
本文部分内容包含 AI 辅助创作,文中代码与数据经人工实测与复核。