">
← 返回项目列表

01 · AI Agent 后端工程 · 团队协作 · 2026.05 – 至今

文鉴 · 高校制度问答助手

面向高校多部门制度文档的问答平台。我在团队中担任 AI Agent 后端工程师,核心负责 Python Agent Harness 与在线可信问答链路,专项负责可信 RAG 与评测。

我的工作 Python Harness 编排 · 在线可信 RAG 与证据门禁 · 一个 Feedback Skill 运行接入
技术栈 Python · FastAPI · MongoDB · Redis · BM25 · 向量检索 · RRF · Reranker
不归我负责 Next.js Web · pi Agent Runtime(TypeScript)· 各节点 Prompt 与模型效果 · 文档解析管线

概览

高校制度文档分散在教务、学生、财务等多个部门,学生查一条规定时常遇到三类问题:找不到归口部门、不同来源版本不一致、不确定这条规定是否适用于自己的情况。

项目打通文档治理、部门路由、混合检索、证据校验四个环节,用 Python 侧的 Agent Harness 把意图识别、查询改写、检索、生成、校验组织成固定流水线——每一步的状态和失败原因都可记录、可回放。

制度问答和学生日常的"查资料"不同:答错比不答更糟。所以这条链路的设计重心不是"让模型多说一点",而是三个约束——答案必须能追溯到 active 的官方 Chunk;没有证据时阻断生成而不是硬答;任何一版答案都必须有显式的校验终态。

我的位置

这是一个多人协作项目。先把边界写清楚,再说我做了什么——下面每一条都能对应到"谁负责、我负责到哪一层、哪些不是我做的"。

Next.js Web │ HTTP / SSE ▼ Python FastAPI Backend ──HTTP──► pi Agent Runtime(TypeScript,队友负责) │ 执行 Intent / Rewrite / Answer / Verify 模型节点 ├── MongoDB 文档 · Chunk · Trace · 反馈 · 持久记忆 └── Redis 工作记忆 · 异步任务状态 Python Worker 消费异步文档入库任务
服务拓扑。Python Backend 是我的主要工作区域;两侧的模型运行时与前端由队友负责。

团队分工

角色提供或负责的内容
导师 / 技术负责人总体架构约束、里程碑、跨模块评审
学校部门老师业务规则、制度文件、标准答案、人工验收
Agent / 模型工程师Intent、Rewrite、Answer、Verify 各节点的模型与 Prompt 效果
文档 / RAG 数据工程师解析、清洗、切块、上传与基础索引数据
前端工程师学生端与管理端交互
Python Harness 的契约、编排、可靠性、Trace、测试;可信 RAG 与评测;一个 Skill 的运行接入

我在调用链中的主责

  1. 01Python 问答主链集成 / Agent Harness 核心主责

    接手基础版本后负责:节点契约、超时、错误分类与重试;pi Runtime 的 Python 调用侧与本地回退;Trace、故障注入与降级。

  2. 02在线可信 RAG / 证据门禁与评测 专项主责

    混合召回 BM25 + 向量 + RRF + Reranker;active 状态与引用校验;文档版本治理;分层评测与消融实验。

  3. 03Feedback Skill 运行接入 协作专项

    把 Harness 的 Trace 与 outcome 对接到反馈链路,联调一个 Skill 真实改变运行行为,并验证失败时能回滚。

明确不是我做的:pi Agent Runtime 的 TypeScript 内部实现、Next.js 前端、各节点的 Prompt 调优与模型选型、文档解析清洗管线。我理解上下游接口,但不把这些表述为个人实现。Memory Context 我只参与联调。

在线链路的可信边界

输入能否作为制度事实使用方式
active 官方 Chunk答案结论与引用的事实依据
组织记忆不能直接使用只能提示回查其 active 官方来源
用户记忆理解用户身份、偏好与上下文
会话历史补全指代与多轮语义
Rule不是事实约束回答行为
Skill不是事实调整 Query、top-k、工作流或回答指令

核心不变量:Memory 帮助理解,官方 active Chunk 提供事实;最终回答必须保留可追溯引用。这条不变量是后面所有门禁设计的依据。

问题

下面三个问题都不是"读代码时顺手发现的",每一条都绑定了一个正式来源:故障注入、红灯测试或状态可观测性缺口。

  1. 存在绕过校验的终态:第三版答案没有对应 Verify

    Trace 显示 Answer 被调用 3 次、Verify 只有 2 次——第三版答案直接流出。同时 Verifier 空状态默认判为成功。这违反了"每一版事实性答案必须有显式校验终态"的验收不变量。

    发现来源:Harness 故障注入——测试桩让 Verifier 连续两次返回失败。

  2. 改写 Query 空召回后,流程仍然进入生成

    只发生一次改写 Query 检索,没有用原始 Query 恢复;原始 Query 已经检索过且为空时,Answer 仍会被调用一次。改写是双刃剑——改得差就偏离原意,而系统当时没有任何回退。

    发现来源:专项红灯测试——3 条断言失败,直接暴露控制流缺口。

  3. 跨节点故障无法定位,收尾也不是幂等的

    没有统一的 RunControlState 与 RunEvent:哪个节点失败、失败类型、重试次数、剩余预算都散在日志里。Answer→Verify 链路中断时只能靠猜。Finalize 也没有幂等键,重复收尾会重复触发副作用。

    发现来源:Trace 与观测字段缺失 + 团队代码评审发现的终态不一致。

架构

用户查询 · POST /api/v1/chat 部门路由 DeptRouter 用户指定 › DeptRouter › Intent 兜底 › Hook 扩展 Memory Context 工作记忆 + 情景记忆 · 帮助理解,不提供事实 Intent 意图识别 pi 执行 Hook / Rule → 最终部门范围 Rule 约束回答行为,不是事实来源 Query Rewrite + Skill Plan pi 执行 混合检索 BM25 + 向量 → RRF → Reranker 改写 Query 空召回时,用原始 Query 有界恢复一次 Evidence Gate · active 二次检查 无证据 → NO_EVIDENCE,不调用 Answer 与 Verifier Answer 生成 → Verify 校验 每一版答案都必须有显式校验终态 pi 执行 Trace · Feedback · 指标 → API 响应 队友负责 pi Agent Runtime(TypeScript): 执行 Intent / Rewrite / Answer / Verify 模型节点 Next.js Web:学生端与管理端 文档管线:解析 · 清洗 · 切块 · 基础索引 我只掌握调用契约、超时与回退 我的核心主责 · Python Agent Harness 节点契约 · 超时 · 错误分类 · 重试 pi Runtime 调用侧与 Python 本地回退 Trace · 故障注入 · 降级路径 RunControlState · RunEvent · 幂等收尾 接手基础版本,非从零搭建 我的专项主责 · 在线可信 RAG 混合召回 · RRF · Reranker active 状态二次检查与引用可追溯 文档版本治理 分层评测与消融实验 协作专项:一个 Feedback Skill 的运行接入
在线问答主链路(左)与职责边界(右)。两道闸门是我主责的核心:检索后的 Evidence Gate 决定"能不能生成",Answer→Verify 的终态约束决定"这一版答案算不算数"。

方案

三段主责对应三类工作:把失败分类并收口终态(Harness)、把证据变成前置门禁(RAG)、把过程变成可观测状态(Trace)。

  1. 01Python Agent Harness:把失败分类,把终态收口

    Answer→Verify 重试闭环重建后覆盖四条路径:首次通过、一次重写通过、两次重写通过、最终失败。每一版答案都对应一次显式 Verify;最终失败走确定性安全响应,不再出现"生成了但没校验"的答案。Verifier 的技术回退也改为失败关闭——pi 返回空、Schema 不合法、启发式无法判断时都不默认放行;请求内连续技术失败则临时禁用 pi Verifier,改走本地路径。

    Provider 侧按类型分层:超时、连接失败、限流、Provider 状态错误分别进入不同域内异常,上层据此决定重试、切换模型、降级还是直接返回。收尾阶段以请求开始生成的 trace_id 作为幂等键——已存在最终 Trace 时跳过重复的收尾副作用,相同终态重复提交为 no-op,冲突终态仍拒绝覆盖。

    为什么不做统一 try-catch:无脑重试会把超时请求越堆越多;一律降级则会把可恢复的错误当成致命错误。分类之后,重试才有意义。

  2. 02在线可信 RAG:Evidence Gate 与原始 Query 有界恢复

    先把契约冻结下来再动手:首次改写 Query 空召回且原始 Query 未参与检索时,保持部门范围不变,用原始 Query 恢复一次;原始 Query 已经参与过,就不重复相同参数的检索;恢复仍然为空则判 NO_EVIDENCE,不调用 Answer 与 Verifier,Trace 记 success=false、自动反馈为 no_evidence 而不是 verifier_fail

    计数语义也一并固定:retrieval_attempt_count 统计实际开始的全部检索,retrieval_recovery_count 只统计恢复调用,Gate 仅形成 RECOVERY_SCHEDULED 时不提前增加实际计数。在线侧再加一道 active 二次检查——召回到的 Chunk 在生成前重新确认仍是 active 版本。

    配套的版本治理:新版本全部索引成功后才归档旧版本;索引失败时应用层补偿删除新版本半成品,旧版本继续 active。历史数据保留,不等于物理删除。

  3. 03RunControlState、RunEvent 与分层计数

    状态从 RFC 变成 Orchestrator 的真实控制数据:初始节点 REQUEST_RECEIVED、状态 RUNNING、各计数归零;SUCCEEDEDFAILED_SAFE 是不可再次转移的终态。计数分三层——answer_version 区分答案版本,answer_rewrites_used 不含初版,verify_rounds_used 每版记一次业务审核,verifier_calls_used 取 Verifier 报告的底层调用数。这样"业务轮次"和"Provider 调用次数"不会再互相污染。

    RunEvent 采用通用字段加受控节点明细:node / executor / strategy / node_result / error_type / node_attempt / latency_ms / next_node / occurred_at 为通用部分,Retrieval 的 is_recovery、Answer 的 answer_version、Verify 的 verify_round 走专属 details;写入未定义的 details 键直接抛 ValueError,避免事件字典无限膨胀。Verify 结果按业务结论、状态控制信号、Trace 观测三层分离,Provider 细节不进状态和对外响应。

    预算策略:DeadlinePolicy 显式注入节点级动态 effective_timeout_ms,Answer 与 Verify 共享同一个剩余时间窗口。

实现细节(对照代码)

文鉴仓库不对外公开。下面是我在自有仓库中实现的同类工程模式,可直接对照源码查看——同一套"预算—异常分层—契约校验"的处理方式。

预算与显式终止

AGENT_MAX_ROUNDS = 4 AGENT_MAX_TOOL_CALLS = 6 AGENT_TOTAL_TIMEOUT_SECONDS = 60.0 async with asyncio.timeout(total_timeout_seconds): return await _run_kitchen_agent(...)

三种终止都是显式异常而不是静默返回:总超时抛 AgentRunTimeoutError,轮次耗尽抛 AgentMaxRoundsExceededError,工具调用超额抛 AgentToolCallLimitExceededError。参数非法(max_rounds <= 0 等)在入口就 ValueError,不进运行时。

Provider 异常分层

except (APITimeoutError, TimeoutError) as exc: -> AIServiceTimeoutError except APIConnectionError as exc: -> AIServiceConnectionError except RateLimitError as exc: -> AIProviderBusyError except APIStatusError as exc: -> AIProviderError

四类 SDK 异常映射到四个域内异常,统一继承 AIServiceError,上层按类型选择重试、切换模型、降级还是直接返回。每次调用都记录 operationmodellatency_msrequest_id——故障定位时不需要再翻原始日志猜。

工具契约 fail-fast

@dataclass(frozen=True, slots=True) class RegisteredTool: ... expected_parameters = tool.arguments_model.model_json_schema() if actual_parameters != tool.definition["function"]["parameters"]: raise RuntimeError(f"tool schema mismatch: {tool.name}")

启动时比对 Pydantic 模型 schema 与真正发给模型的 function definition,不一致直接启动失败;工具名重复同样报错。把"Schema 错误"这一类失败挡在运行时之前,而不是等模型调用时才炸。

关键决策

为什么把终态、计数和预算做成显式状态对象,而不是继续看日志?

接手时 Harness 已经有节点和调用,但"哪个节点失败、重试了几次、还剩多少预算"散在日志里,故障只能靠猜。更关键的是,Answer 生成了几版、Verify 校验了几版没有权威来源——这正是第三版答案能绕过校验的根本原因。把 RunControlState 变成 Orchestrator 的真实控制数据之后,SUCCEEDEDFAILED_SAFE 成为不可再转移的终态,分层计数让"业务轮次"和"Provider 调用次数"不再互相污染。代价是多写一层状态对象和事件契约,换来的是故障定位从"翻日志猜"变成"读一次状态"。

为什么改写失败要回退原始 Query,而不是直接拒绝?

改写是双刃剑——改得好提升召回,改得差直接偏离原意。失败就拒绝,等于把改写环节的风险全压在用户身上;完全不改写,又浪费了意图理解能力。有界恢复是几种方案里风险最可控的折中:改写优先试一次,空召回且原始 Query 未参与时用原始 Query 恢复一次,仍为空才判 NO_EVIDENCE。关键约束是"只恢复一次、不重复相同参数的检索"——否则就退化成没有意义的盲重试。

为什么 Evidence Gate 放在生成之前,而不是生成后校验?

制度问答里,错误答案比没有答案更糟。生成后校验只能降低错误率,不能避免错误;Evidence Gate 的职责是阻断——检索为空或证据不足时直接返回"无相关制度",根本不调用生成模型。这也是为什么无证据的 Trace 要记成 no_evidence 而不是 verifier_fail:前者是"没有事实可答",后者是"答得不对",两类问题要走完全不同的改进路径。

点选一条路径,看 Gate 给出什么状态和计数:
HAS_EVIDENCE
  • retrieval_attempt_count1
  • retrieval_recovery_count0
  • Answer 调用1
  • Verify 调用1

口径:以上是冻结契约下的控制流与计数,已在离线、关闭真实模型调用的环境中通过专项验证。相关性阈值尚未在冻结评测集上评审,因此这里不给出阈值数字——拍脑袋定阈值正是这个项目明确禁止的做法。

为什么技术异常一律失败关闭,而不是降级放行?

Verifier 遇到 pi 返回空、Schema 不合法或启发式无法判断时,"默认成功"是最省事也最危险的选择——它会把不可用伪装成可用,和第三版答案绕过校验是同一类问题。我们改成失败关闭:无法判断就判为未通过;请求内连续技术失败则临时禁用 pi Verifier 并改走本地路径;降级信号传给状态机,不写进业务结论。代价是短期内拒绝率上升,但这条链路的可信性优先级高于回答量。

为什么 Verify 结果要分三层传?

业务结论(passed / score / issues)、状态控制信号(Provider 调用次数、请求内禁用、降级信号)、Trace 观测(执行器、状态、耗时、错误类型)混在一个对象里,会让"业务判断"被"技术噪声"污染。三层分离之后,Orchestrator 各取所需:状态机只看 control,业务只看 result,Provider 细节只进 Trace 的 verification_observations,不进 State 也不进对外的 answer.verification

结果

这一节只写有证据支撑的结论。所有改造都在离线环境验证(memory 存储、关闭真实模型调用、Python 3.12.x),没有线上流量,因此不给出召回率、可用性和延迟类业务指标。

21 项

改造闭环台账

每项都绑定发现来源 · 红灯证据 · 修改范围 · 验收口径

4 条

Verifier 终态路径全覆盖

首次通过 · 一次重写 · 两次重写 · 最终失败

2 道

闸门

检索后 Evidence Gate · 生成后校验终态

已闭环的行为

  • 每一版答案都有显式校验终态。修复前故障注入显示 Answer 调用 3 次、Verify 只有 2 次;修复后四条终态路径全覆盖,最终失败走确定性安全响应。
  • 无证据时阻断生成。Gate 给出 NO_EVIDENCE 时不再调用 Answer 与 Verifier;Trace 记 success=false、反馈归为 no_evidence 而非 verifier_fail
  • 有界恢复只做一次。改写 Query 空召回且原始 Query 未参与时恢复一次;原始 Query 已参与则禁止相同参数盲重试,计数不虚增。
  • 技术异常失败关闭。pi 返回空、Schema 不合法、启发式无法判断都不再默认放行;请求内连续技术失败临时禁用 pi Verifier 并改走本地路径。
  • 收尾幂等。以请求开始生成的 trace_id 作为幂等键,重复收尾为 no-op,冲突终态拒绝覆盖。
  • 状态成为真实控制数据。RunControlState 不再只是设计文档,RunEvent 覆盖关键路径,分层计数区分业务轮次与 Provider 调用。

失败案例

Query: "教师请假应该向哪个部门申请?"

问题: 黄金部门是人事处,Router 判为学生处,Intent 判为人事处,修改前最终范围取了 Router 的结果

为什么不能简单修: 对照样本"学生请假应该向哪个部门申请?"里 Router 对而 Intent 错——直接相信 Intent 或改成无条件并集都不能稳定解决,必须显式区分问题主体

处理: 引入 SubjectContext(学生 / 教职工),并把可信身份与权限角色分离,部门范围交由显式裁决

Query: 同一部门下标题相同的两份制度文档

问题: 自动判定 supersedes 过于粗糙,无法区分"补充""解释"和"部分修订"

影响: 旧版本可能被过早归档并退出在线索引,导致本来能召回的规定变成无证据

状态: 已登记为技术债,未解决

场景: 新版本文档索引中途失败

问题: 应用层补偿删除新版本半成品、旧版本继续 active,但补偿本身也可能失败

影响: 进程崩溃或跨存储清理再次失败时,可能残留不一致状态

状态: 需要后续对账或持久化补偿机制,当前未接入

已知边界

这一节是这个项目里我最看重的部分:知道什么还没被证明,和知道什么已经成立一样重要。

  • 01
    没有线上指标。未执行真实 Query 集的 Recall@K、Precision@K、MRR 与 P95 对比,因此不声称召回效果已经提升;线上延迟、成本与多实例行为也都没有观测数据。
  • 02
    评测集只有 10 条用例。只能作为第一版冒烟与回归集,不足以支撑具有生产统计说服力的结论。
  • 03
    相关性阈值尚未评审。Evidence Gate 的阈值与恢复策略必须在冻结评测集上验证之后才能定,当前明确禁止拍脑袋修改。
  • 04
    文档关系判定过粗。同部门同标题自动判 supersedes,无法正确表达「补充」「解释」与「部分修订」三种关系。
  • 05
    部门结果没有完整合并决策。DeptRouter 与 IntentAgent 的结论当前只是按优先级串联,缺少统一的裁决逻辑。
  • 06
    幂等与补偿范围有限。Finalize 幂等只保证单进程、同一 trace_id;跨进程协调与失败补偿未接入。文档版本切换的应用层补偿也无法覆盖进程崩溃。
  • 07
    动态超时未全覆盖。只有 Answer 与 Verify 接入了节点级动态 effective_timeout_ms,Query Rewrite、Retrieval 等节点尚未统一接入请求级超时与取消语义。
  • 08
    SSE 不是真正的流式。当前是完整回答生成后按字符分片输出,不是 LLM token 级流式。