编排与引用约束
用状态和预算约束研究过程,再对引用结构与逐句证据支持分别校验。
图中的职责
工作流状态不仅保存聊天消息,还保存原始问题、研究任务、证据池、按片段 ID 索引的证据、预算进度、警告与错误。把结构化任务单独保存,可以避免后续节点反复从自然语言计划里猜测“哪些步骤已经完成”。
Supervisor 根据当前状态选择 direct、planner、researcher、writer 或结束。简单问题可跳过模型路由直接检索;直接路径证据不足时可以升级到规划。planner 生成结构化任务后,后续路由以任务状态为准,避免模型跳过未完成研究或反复规划。
def build_graph() -> StateGraph[AgentState]:
"""构建 Supervisor 委派制多 Agent 协作图。
返回未编译的 StateGraph,调用方可根据需要:
- .compile() 获取 CompiledStateGraph
- 注入 checkpointer(M3 多轮会话持久化用)
- 设置 interrupt_before(调试用)
Returns:
已添加所有节点和边的 StateGraph builder
"""
builder = StateGraph(AgentState)
# 添加所有节点
builder.add_node("supervisor", supervisor_node)
builder.add_node("direct", direct_node)
builder.add_node("planner", planner_node)
builder.add_node("researcher", researcher_node)
builder.add_node("writer", writer_node)
# START → supervisor(入口)
builder.add_edge(START, "supervisor")
# 所有 worker 节点 → 条件边 → supervisor(继续)或 END(终止)
# supervisor 通过 Command(goto=...) 动态路由到各 worker
# path_map: should_continue 返回 Literal["supervisor","END"],需显式映射到节点/哨兵
for worker in ["direct", "planner", "researcher", "writer"]:
builder.add_conditional_edges(
worker,
should_continue,
path_map={"supervisor": "supervisor", "END": END},
)
return builder
researcher 将多个待执行任务放入线程池,各任务保留独立证据与工具预算,完成后按计划顺序合并。达到 supervisor 迭代上限后不再委派新任务;模型调用和工具调用另有预算,不能只靠提示词要求“不要调用太多次”。
| 节点 | 主要职责 | 输出或终止依据 |
|---|---|---|
| supervisor | 按现有状态与预算选择下一步 | 委派、升级、结束与审批 |
| direct | 直接检索并尝试有据回答 | 可回答结果或证据缺口 |
| planner | 将复杂问题转成结构化子任务 | 每项任务的问题、状态与身份 |
| researcher | 检索各子任务并累积证据 | 任务结果、证据 ID、错误和轨迹 |
| writer | 从证据池选择内容并综合 | 最终答案、引用与诊断 |
简单问题的启发式直接路径可以省去一次模型路由。进入研究后,未完成任务、已有最终答案和迭代上限优先决定合法下一步,避免模型通过一句委派文本绕过任务状态。
并行研究怎样合并
每个研究任务有自己的证据缓冲、调用预算和结果记录,多任务可以在线程池并行运行。主节点按计划顺序收集 outcome,再按片段 ID 去重合并证据;任务完成顺序不同,不应使最终证据身份随机变化。
共享状态只保留每个任务必要的最终助手说明,不把整份内部 ReAct 对话无条件复制回主会话。这样既能追踪任务是否完成,也减少中间过程挤占最终写作窗口。
线程任务的取消属于协作式停止:尚未开始的 future 可以取消,正在执行的工作还依赖取消信号和各调用的退出点。它不是可以随时强制终止任意第三方模型请求的进程隔离机制。
高成本计划先审批
当任务数量或预计调用成本达到配置阈值,图在 researcher 执行前暂停。审批记录包含原始问题、任务与成本估计,用户可以修改任务、批准或取消;恢复时使用检查点里的计划,不重新计算另一份计划。
演示用包含“深入”“比较”等词的问题触发同样的审批界面;批准后执行本地检索,取消后不写入完成答案。这个触发规则和成本估计只用于演示,真实项目由 supervisor 配置和运行状态决定。
审批面向尚未执行的研究成本,并不撤销此前已经完成的直接检索或规划调用。用户修改计划后,研究应执行批准的那份任务,而不是继续使用界面修改前的隐藏副本。
预算也分层:supervisor 迭代限制委派次数,researcher 步数与工具预算限制子任务工作,运行超时限制整体占用。达到某层上限时,应保留原因与已有证据,不能靠换一个节点继续无界运行。
引用结构不等于支持结论
结构校验先检查 [N] 是否有效、是否越界,再只保留答案实际使用的证据,并按首次出现顺序连续编号:
def enforce_citations(
answer: str, citations: list[dict[str, Any]]
) -> tuple[str, list[dict[str, Any]]]:
"""Reject invalid citations and keep only citations actually used by an answer.
Marker parsing is evaluation-only code, so an unexpected validation failure must
fail closed to the ordinary insufficient-evidence response rather than break an
answer request.
"""
try:
parsed = parse_citation_markers(answer)
if parsed.malformed:
return INSUFFICIENT_EVIDENCE_ANSWER, []
markers = parsed.markers
if any(number > len(citations) for number in (marker.number for marker in markers)):
return INSUFFICIENT_EVIDENCE_ANSWER, []
if not markers:
if answer.strip() == INSUFFICIENT_EVIDENCE_ANSWER:
return answer, []
return INSUFFICIENT_EVIDENCE_ANSWER, []
# Repeated markers map to the same citation; numbering follows first
# occurrence so the public contract remains contiguous and unambiguous.
renumbering: dict[int, int] = {}
normalized_parts: list[str] = []
used_citations: list[dict[str, Any]] = []
previous_end = 0
for marker in markers:
new_number = renumbering.get(marker.number)
if new_number is None:
new_number = len(used_citations) + 1
renumbering[marker.number] = new_number
used_citations.append(citations[marker.number - 1])
normalized_parts.append(answer[previous_end:marker.start])
normalized_parts.append(f"[{new_number}]")
previous_end = marker.end
normalized_parts.append(answer[previous_end:])
return "".join(normalized_parts), used_citations
except Exception: # noqa: BLE001 - validation must fail closed in the request path.
return INSUFFICIENT_EVIDENCE_ANSWER, []
启用支持校验时,随后由模型判断每句结论能否获得证据支持。未通过的结论被移除;校验器失败或没有留下有据内容时返回拒答,同时保留诊断信息。来源命中只能证明检索到了某篇文档,不能证明答案忠实于它。
三种失败的区别
| 情况 | 举例 | 检查层 |
|---|---|---|
| 引用无效 | 只有两条证据却写 [3] | 编号与结构校验 |
| 结论不受支持 | 证据介绍一种方法,回答却给出资料没有的性能数字 | 模型支持校验 |
| 资料未覆盖问题 | 所有候选都与所问属性无关 | 证据判断与拒答 |
结构归一化按首次使用顺序重编号。例如答案先用 [4] 再用 [2],公开结果可以规范为 [1]、[2],引用列表同步只保留实际使用的两项。重复引用继续指向同一证据,不会生成新的来源身份。
支持校验按配置启用,依赖模型判断;它增加约束,却不等于形式化证明。校验器可能结合其他已检索片段判断支持,因此仍要核对具体编号对应的原文是否清晰,不能只凭出现合法引用就停止审查。
流式输出也必须先校验
直接逐 token 输出会让用户先看到未经验证的结论。_ValidatedStreamer 先积累完整句,再校验并移除不受支持部分,之后才发送增量:
def _flush(self, force: bool) -> None:
prefix, remainder = _split_complete_sentences(self._buffer, force)
self._buffer = remainder
if not prefix.strip():
return
if any(
marker.number > len(self._citations)
for marker in parse_citation_markers(prefix).markers
):
self._dropped += max(1, len(_claims_for_validation(prefix, True)))
return
verdicts = verify_cited_claims(
prefix,
self._chunks,
self._llm_config,
self._policy,
include_uncited=True,
)
if verdicts is None:
self._validation_failed = True
return
self._dropped += sum(1 for supported in verdicts.values() if not supported)
supported = _drop_unsupported_claims(prefix, verdicts, include_uncited=True)
if not supported.strip():
return
streamed = self._renumber(supported)
self._stream_callback(streamed)
self._streamed.append(streamed)
用户看到的流因此不是模型原始 token 的无条件转发。运行时先缓冲句子或文本批次,校验后才发送,最后保存的回答应与已经显示的有据内容保持一致。首段显示可能更慢,但避免先传播无依据结论、完成时再突然删掉整段。
拒答与降级如何保留信息
没有证据、证据不足、检索失败和校验不可用应通过不同诊断原因表达。前两者提示补充资料或缩小问题,后两者提示依赖或执行异常;都返回空泛的“无法回答”,会使用户无法判断下一步。
部分句子有据时可保留已通过部分,并说明移除的结论;全部无法支持或校验无法完成时失败关闭。拒答策略与状态完成要分开:一轮可以正常完成并给出拒答,也可以因依赖错误以失败结束。
演示直接摘录命中原文并附编号,没有执行这套模型语义校验。前端的证据、委派路径和运行状态可以操作,真实语义保证需要模型服务和后端测试共同验证。
![CitedRAG 对话页:回答的每个结论后面带 [1][2] 引用编号,下方展开证据](/covers/citedrag-art.webp)