01 · AI Agent 后端工程 · 团队协作 · 2026.05 – 至今
文鉴 · 高校制度问答助手
面向高校多部门制度文档的问答平台。我在团队中担任 AI Agent 后端工程师,核心负责 Python Agent Harness 与在线可信问答链路,专项负责可信 RAG 与评测。
概览
高校制度文档分散在教务、学生、财务等多个部门,学生查一条规定时常遇到三类问题:找不到归口部门、不同来源版本不一致、不确定这条规定是否适用于自己的情况。
项目打通文档治理、部门路由、混合检索、证据校验四个环节,用 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 消费异步文档入库任务团队分工
| 角色 | 提供或负责的内容 |
|---|---|
| 导师 / 技术负责人 | 总体架构约束、里程碑、跨模块评审 |
| 学校部门老师 | 业务规则、制度文件、标准答案、人工验收 |
| Agent / 模型工程师 | Intent、Rewrite、Answer、Verify 各节点的模型与 Prompt 效果 |
| 文档 / RAG 数据工程师 | 解析、清洗、切块、上传与基础索引数据 |
| 前端工程师 | 学生端与管理端交互 |
| 我 | Python Harness 的契约、编排、可靠性、Trace、测试;可信 RAG 与评测;一个 Skill 的运行接入 |
我在调用链中的主责
-
01Python 问答主链集成 / Agent Harness 核心主责
接手基础版本后负责:节点契约、超时、错误分类与重试;pi Runtime 的 Python 调用侧与本地回退;Trace、故障注入与降级。
-
02在线可信 RAG / 证据门禁与评测 专项主责
混合召回 BM25 + 向量 + RRF + Reranker;active 状态与引用校验;文档版本治理;分层评测与消融实验。
-
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 提供事实;最终回答必须保留可追溯引用。这条不变量是后面所有门禁设计的依据。
问题
下面三个问题都不是"读代码时顺手发现的",每一条都绑定了一个正式来源:故障注入、红灯测试或状态可观测性缺口。
-
存在绕过校验的终态:第三版答案没有对应 Verify
Trace 显示 Answer 被调用 3 次、Verify 只有 2 次——第三版答案直接流出。同时 Verifier 空状态默认判为成功。这违反了"每一版事实性答案必须有显式校验终态"的验收不变量。
发现来源:Harness 故障注入——测试桩让 Verifier 连续两次返回失败。
-
改写 Query 空召回后,流程仍然进入生成
只发生一次改写 Query 检索,没有用原始 Query 恢复;原始 Query 已经检索过且为空时,Answer 仍会被调用一次。改写是双刃剑——改得差就偏离原意,而系统当时没有任何回退。
发现来源:专项红灯测试——3 条断言失败,直接暴露控制流缺口。
-
跨节点故障无法定位,收尾也不是幂等的
没有统一的 RunControlState 与 RunEvent:哪个节点失败、失败类型、重试次数、剩余预算都散在日志里。Answer→Verify 链路中断时只能靠猜。Finalize 也没有幂等键,重复收尾会重复触发副作用。
发现来源:Trace 与观测字段缺失 + 团队代码评审发现的终态不一致。
架构
方案
三段主责对应三类工作:把失败分类并收口终态(Harness)、把证据变成前置门禁(RAG)、把过程变成可观测状态(Trace)。
-
01Python Agent Harness:把失败分类,把终态收口
Answer→Verify 重试闭环重建后覆盖四条路径:首次通过、一次重写通过、两次重写通过、最终失败。每一版答案都对应一次显式 Verify;最终失败走确定性安全响应,不再出现"生成了但没校验"的答案。Verifier 的技术回退也改为失败关闭——pi 返回空、Schema 不合法、启发式无法判断时都不默认放行;请求内连续技术失败则临时禁用 pi Verifier,改走本地路径。
Provider 侧按类型分层:超时、连接失败、限流、Provider 状态错误分别进入不同域内异常,上层据此决定重试、切换模型、降级还是直接返回。收尾阶段以请求开始生成的
trace_id作为幂等键——已存在最终 Trace 时跳过重复的收尾副作用,相同终态重复提交为 no-op,冲突终态仍拒绝覆盖。为什么不做统一 try-catch:无脑重试会把超时请求越堆越多;一律降级则会把可恢复的错误当成致命错误。分类之后,重试才有意义。
-
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。历史数据保留,不等于物理删除。
-
03RunControlState、RunEvent 与分层计数
状态从 RFC 变成 Orchestrator 的真实控制数据:初始节点
REQUEST_RECEIVED、状态RUNNING、各计数归零;SUCCEEDED与FAILED_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,上层按类型选择重试、切换模型、降级还是直接返回。每次调用都记录 operation、model、latency_ms 与 request_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 的真实控制数据之后,SUCCEEDED 与 FAILED_SAFE 成为不可再转移的终态,分层计数让"业务轮次"和"Provider 调用次数"不再互相污染。代价是多写一层状态对象和事件契约,换来的是故障定位从"翻日志猜"变成"读一次状态"。
为什么改写失败要回退原始 Query,而不是直接拒绝?
改写是双刃剑——改得好提升召回,改得差直接偏离原意。失败就拒绝,等于把改写环节的风险全压在用户身上;完全不改写,又浪费了意图理解能力。有界恢复是几种方案里风险最可控的折中:改写优先试一次,空召回且原始 Query 未参与时用原始 Query 恢复一次,仍为空才判 NO_EVIDENCE。关键约束是"只恢复一次、不重复相同参数的检索"——否则就退化成没有意义的盲重试。
为什么 Evidence Gate 放在生成之前,而不是生成后校验?
制度问答里,错误答案比没有答案更糟。生成后校验只能降低错误率,不能避免错误;Evidence Gate 的职责是阻断——检索为空或证据不足时直接返回"无相关制度",根本不调用生成模型。这也是为什么无证据的 Trace 要记成 no_evidence 而不是 verifier_fail:前者是"没有事实可答",后者是"答得不对",两类问题要走完全不同的改进路径。
- 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 级流式。