架构说明¶
1. 分层与依赖方向¶
api / cli
↓
runtime(装配层)
↓ ↘
graph(DAG 装配) agents(9 个 Agent)
↓ ↘
engine(纯执行内核) llm / tools / report
硬约束:engine/ 不 import llm/、tools/、agents/。
节点需要的所有外部能力通过 Services 注入(结构化调用、工具调用、事件发射、预算、缓存)。
这条约束换来两个好处:引擎可以脱离 LLM 单测;更换模型或数据源不会牵动调度逻辑。
2. 状态通道¶
状态是一组声明式通道,每个通道有自己的 reducer。通道必须先声明后使用,
拼写错误会在 GraphBuilder.validate() 阶段报错,而不是运行时静默丢字段。
| 通道 | reducer | 生产者 | 说明 |
|---|---|---|---|
question / as_of |
last | 外部 | 初始输入 |
entity |
last | resolve_entity | 标的(名称 / 代码 / 行业) |
plan |
last | plan | 子问题、所需数据、启用分析师 |
evidence |
append_unique(id) | retrieve(3 路并发) | 证据,按内容哈希去重 |
evidence_gaps |
append | retrieve | 数据缺口,进入报告"待核实" |
research_data |
merge_map | retrieve | 按维度聚合的事实 / 指标 / 新闻 |
evidence_summary |
last | evidence_merge | 数量、分级分布、来源数 |
analyst_reports |
append | analysts(3 路并发) | 三份结构化分析 |
analyst_summary |
last | analyst_merge | 立场、置信度、缺口 |
bull_argument / bear_argument |
last | debate 内部 | 每轮覆盖,供下一轮互相反驳 |
debate_rounds |
append | round_judge | 每轮打分(含 new_info_ratio) |
verdict |
last | verdict | 采纳 / 驳回的结论 |
draft_report |
last | editor_draft | 报告草稿 |
citation_audit |
last | fact_check | 引用审计结果 |
risk_review |
last | compliance | 合规结论 |
final_report |
last | revise → finalize | 终稿 |
为什么不用一个大 dict:并行节点同时写同一字段时,结果取决于完成顺序,属于"本地跑十次都对、 线上偶发错一次"的隐性竞态。reducer 让"追加 / 覆盖 / 合并"变成显式声明,也让快照天然可序列化。
3. 节点类型¶
| 类型 | 是否调用 LLM | 职责 | 本项目的实例 |
|---|---|---|---|
ToolNode |
否 | 确定性计算 / 取数 / 校验 | resolve_entity、fact_check、revise、finalize |
AgentNode |
是(结构化输出) | 单次调用 → 一个 Pydantic 对象 | plan、3 分析师、editor_draft |
JudgeNode |
是(temperature=0) | 打分 / 裁决 | round_judge、compliance |
FanOut |
— | 运行时展开 N 份并发 | retrieve、analysts |
Aggregate |
— | fan-in + 法定人数 | evidence_merge、analyst_merge |
LoopNode |
— | 有界循环 + 终止判定 | debate |
4. 失败传播语义¶
| 策略 | 语义 | 典型用途 |
|---|---|---|
ABORT |
关键路径失败则整个 run 失败 | 标的识别不出来(后续全是垃圾) |
SKIP |
该节点放弃,下游用已有状态继续,run 标记 PARTIAL |
某个维度取数失败 |
DEGRADE |
使用节点自带的降级产物(合法但标注数据不足) | 分析师模型调用失败 |
PARTIAL 是有意义的状态:报告仍然交付,但"待核实问题"里会写明哪个维度降级了。
5. 事件与可观测¶
22 种事件(run.* / node.* / fanout.* / loop.* / tool.* / budget.* / report.ready),
全部落 events 表,seq 在 run 内单调。SSE 端点的 Last-Event-ID 直接映射为
WHERE seq > ? 查询,所以"前端刷新丢进度"这类问题从设计上就不存在。
前端时间线、辩论面板、预算表全部由事件派生——不为 UI 单独维护一套状态。
6. 关键时序(一次完整运行)¶
POST /api/runs
└→ RunManager.start 写 runs(QUEUED),起 asyncio.Task
└→ GraphRunner.run 发 run.started
└→ Scheduler 依赖计数 → 就绪队列 → 并发上限 8
每个节点:node.started → 执行(带超时/重试)
→ blackboard.apply(Patch)
→ save_node + save_snapshot
→ node.completed(带 token / 耗时 / 降级标记)
└→ run.completed / run.failed
SSE 消费者:先补历史(seq > Last-Event-ID),再订阅增量
7. 为什么没有消息队列¶
单进程内 dict[run_id, Task] + 一个全局信号量就够:瓶颈是模型调用,不是调度吞吐。
引入 Celery/Redis 后,checkpoint 语义、取消语义、事件顺序都要重新设计一遍。
扩展路径:把 RunManager 的任务提交抽成 JobQueue 接口,换 DB 队列 + 独立 worker 即可,
引擎层不需要任何改动(因为快照是纯 JSON)。