DS2API引用标记替换原理:[citation:N]→Markdown链接完整算法
【免费下载链接】ds2apiDeepSeek-Compatible Middleware Interface: A technical exploration project in Go, focusing on high-concurrency protocol adaptation. It serves as a reference implementation for converting diverse web protocols into standardized formats.项目地址: https://gitcode.com/GitHub_Trending/ds/ds2api
DS2API 是一个 DeepSeek 兼容的 Go 语言中间件接口项目,专注高并发协议适配。它会把 DeepSeek 上游返回的引用标记(citation marker)自动替换成标准 Markdown 链接:正文里的[citation:1]最终会变成[1](https://...),用户点击即可跳转到真实来源网页。这篇文章完整拆解这套「引用标记 → Markdown 链接」的替换算法,包括 SSE 流中的 URL 采集、索引对齐、正则替换和容错兜底。
一、引用标记从哪来?
启用联网搜索后,DeepSeek 的 SSE(Server-Sent Events)流里会同时出现两类数据:
| 数据 | 位置 | 示例 |
|---|---|---|
| 可见正文中的引用标记 | response/content文本片段 | 这是一条更新[citation:1] |
| 引用来源元数据 | 流中的url/cite_index字段 | {"url": "https://...", "cite_index": 1} |
问题在于:标记出现在正文里,而 URL 出现在另外的数据块里,且两者到达顺序不固定——元数据甚至可能晚于FINISHED状态才到达。DS2API 因此拆成了两阶段:先采集,后替换。
二、阶段一:从 SSE 流采集引用 URL
采集器 citationLinkCollector 维护三份数据:
ordered:URL 按出现顺序排列的列表(兜底用)explicitRaw:cite_index → url的显式索引映射hasZeroIdx:是否出现过 0 号索引(用于判断上游是否为 0 基编号)
每解析出一个 SSE 数据块,就调用一次 ingestChunk。它通过 walkValue递归遍历整个 JSON 结构(任意嵌套深度),在 captureURLAndIndex 中做三件事:
- 只接受
http:///https://开头的合法网页 URL; - 把 URL 追加到
ordered列表; - 如果同一个对象里带有
cite_index,就同时写入explicitRaw映射(同一索引首次出现优先)。
采集的调用入口在 CollectStream:整个流消费期间每个数据块都会喂给采集器。值得注意的是,即使收到FINISHED状态,代码 仍会继续扫描、捕获晚到的引用元数据行,直到[DONE]才停止——这是保证链接不丢失的关键细节。
三、索引对齐与冲突消解
最终映射由 build() 生成,规则是「显式索引优先,顺序列表兜底」:
- 先写入所有合法的正数显式索引(
cite_index → url); - 再用
ordered列表按 1 基(第 1 个 URL 对应编号 1)补位,已存在的编号不会被覆盖。
如果上游使用 0 基编号(出现了cite_index: 0),buildNormalizedExplicit 会额外生成一份「+1 偏移」候选。当同一编号被两个来源竞争时,由 preferURLForIndex 裁决:以 URL 在流中实际出现的顺序为准——出现在正文第 N 个标记之前的 URL,才是该标记真正指向的来源。
四、阶段二:正则替换为 Markdown 链接
替换核心是 ReplaceCitationMarkersWithLinks,匹配模式为:
(?i)\[(citation|reference):\s*(\d+)\]即同时兼容[citation:N]和[reference:N]两种标记(大小写不敏感、冒号后允许空格)。对每个匹配项按以下顺序处理:
- 解析出编号
idx; - 若标记是
reference类型且文本中存在 0 号标记(见下文 0 基兼容),查找编号取idx + 1; - 用编号去查链接表,查到则输出
N; - 查不到则原样保留标记文本,绝不猜测 URL(fail-safe)。
输出示例(来自单元测试):
| 上游原始文本 | DS2API 输出 |
|---|---|
这是一条更新[citation:1],更多信息见[citation:2]。 | 这是一条更新[1](https://example.com/news-1),更多信息见[2](https://example.com/news-2)。 |
注意一个细节:链接文字显示的是标记原文的编号([N]),而不是内部查找用的编号——0 基场景下[reference:0]会显示为0,与上游编号保持一致。
五、0 基 reference 编号兼容
部分上游对reference类型使用 0 起编号。hasZeroBasedReferenceMarker 会先扫描全文,只要发现任意[reference:0],就判定该文本为 0 基,随后所有reference标记的查找编号统一 +1;而citation标记始终保持 1 基,两者可共存互不干扰(见混合场景测试)。
六、流式与非流式的差异处理
非流式:等整个响应收齐,在 BuildTurnFromCollected 中对完整文本做一次替换,此时所有 URL 元数据大概率已到,替换率最高。
流式要处理「半截标记」问题:正文片段[citation:可能刚发了一半,此时链接表里还没有对应 URL。DS2API 的对策有两层:
- IsCitation 识别以
[citation:开头的片段,StreamAccumulator 将其标记为CitationOnly暂不向下游推送,避免用户看到残缺标记; - 最终定稿(finalize)阶段再对完整文本执行替换,替换不上的残留标记由 StripReferenceMarkers 统一清除(流式场景默认开启)。
七、算法小结
SSE 流 ──► 采集器(递归找 url/cite_index) ├─ explicitRaw(显式索引,优先) └─ ordered(顺序兜底,1 基补位) 完整文本 ──► 正则匹配 [citation:N]/[reference:N] ├─ 查到 URL ──► 替换为 N └─ 查不到 URL ──► 保留原文 / 流式定稿时清除整套算法的设计思路可以概括为三点:采集与替换分离以应对乱序到达、显式索引优先 + 顺序兜底保证编号可靠、查不到就保留避免生成错误链接。相关源码与测试可参考:
- 引用 URL 采集器:internal/sse/citation_links.go
- 流消费与采集入口:internal/sse/consumer.go
- 替换算法实现:internal/httpapi/openai/shared/citation_links.go
- 流式缓冲处理:internal/httpapi/openai/shared/stream_accumulator.go
- 完整行为验证:internal/httpapi/openai/citation_links_test.go
- 项目架构说明:docs/ARCHITECTURE.md
【免费下载链接】ds2apiDeepSeek-Compatible Middleware Interface: A technical exploration project in Go, focusing on high-concurrency protocol adaptation. It serves as a reference implementation for converting diverse web protocols into standardized formats.项目地址: https://gitcode.com/GitHub_Trending/ds/ds2api
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考