跳到主要内容

智能评标

项目概述

bid.ai 是一个生产级全栈招投标智能评审平台。后端基于 Litestar + SQLModel + PostgreSQL + Temporal,前端基于 Vue 3 + Ant Design Vue,BFF 基于 Node.js/Koa。

核心能力:

  • AI 辅助评标:自动解析招标/投标文件,生成评审要点,AI 评分
  • 专家评标工作流:支持多专家、多阶段评审协作
  • 评标报告生成:LLM + 模板 → DOCX 报告,全流程可追溯
  • 财务/业绩智能评审:基于 RAG + LLM 的专项 AI Agent 评审

技术栈

层级技术用途
后端框架Litestar 2.xASGI Web 框架
ORMSQLModel + SQLAlchemy 2.x类型安全数据库操作
数据库PostgreSQL 16 + asyncpg主数据库
工作流引擎Temporal 1.24异步任务编排 (API/Worker 分离)
包管理uvPython 依赖管理
测试pytest + Testcontainers集成测试(真实 PG)
配置Pydantic Settings环境变量配置
日志Loguru结构化日志
前端Vue 3 + TypeScript + Ant Design Vue管理控制台
前端构建Vite + UnoCSS前端工程化
状态管理Pinia前端状态管理
BFFNode.js + Koa + TypeScript后端代理层
BFF 数据库MySQLBFF 数据存储
存储华为 OBS / MinIO / 本地对象存储(可切换 Driver)
LLMQwen3 / 自定义AI 评审、报告生成
RAGRagFlow知识库检索

架构总览

┌────────────────────────────────────────────────────────────┐
│ Frontend (Vue 3 + AntD) │
│ views: AI评标 / 文档编辑 / 日志管理 / 设置 │
└──────────────────────┬─────────────────────────────────────┘
│ HTTP
┌──────────────────────▼─────────────────────────────────────┐
│ BFF (Node.js/Koa) │
│ 代理 /auth /api/proxy → 后端 + 聊天/文件上传 │
└──────────────────────┬─────────────────────────────────────┘
│ HTTP
┌──────────────────────▼─────────────────────────────────────┐
│ API Service (Litestar) │
│ Controllers → Services → Temporal Client → Temporal Svr │
│ /v1/{bid|agent|data_center|report|job|...} │
└──────────────────────┬──────────────┬──────────────────────┘
│ │
┌────────────▼──┐ ┌──────▼────────────┐
│ PostgreSQL │ │ Temporal Server │
│ (asyncpg) │ │ + Temporal UI │
└───────────────┘ └──────┬────────────┘

┌───────────▼────────────┐
│ Worker Service │
│ (Temporal Workers) │
│ 监听多个 Task Queue │
└───────────────────────┘

职责划分

  • API Service (app/main.py): 处理 HTTP 请求、验证权限、启动 Workflow、查询状态
  • Worker Service (app/worker/main.py): 监听 Temporal 队列、执行 Workflow/Activity、更新数据库

后端目录结构

app/
├── main.py # Litestar 应用入口,注册路由/插件/中间件
├── config.py # Pydantic Settings 配置管理
├── plugins.py # DatabasePlugin, TemporalPlugin (DI 注入)
├── middleware.py # Request ID 中间件
├── guards.py # X-API-Key 认证守卫
├── log.py # Loguru 日志配置

├── core/ # 共享内核(不依赖业务模块)
│ ├── storage/ # 统一对象存储抽象 (OBS/MinIO/Local)
│ ├── temporal_utils.py # Temporal 工具函数
│ ├── decorators.py # 通用装饰器
│ └── date_utils.py # 日期工具

├── database/ # 数据库核心
│ ├── core.py # SessionManager (asyncpg engine)
│ ├── base.py # AsyncModel 基类
│ └── mixins.py # Mixins

├── common/ # 通用
│ ├── response.py # ApiResponse[T] / ApiPageResponse[T]
│ ├── encryption.py # 加密工具
│ └── utils.py # 工具函数

├── exceptions/ # 统一异常处理
│ └── exceptions.py # BusinessException 等 + handler

├── bid/ # === 核心投标评审模块 ===
│ ├── project/ # 项目管理 (CRUD + 专家/投标人管理)
│ ├── bid_evaluation/ # 投标评审 (AI+专家评审核心)
│ ├── report/ # 评标报告生成 (LLM+Temporal+DOCX)
│ ├── ai_ranking/ # AI 排名
│ ├── point_kv_analysis/ # 要点 K/V 分析
│ ├── project_score_factor/ # 项目评分因素
│ ├── project_unified_review_point/ # 项目统一评审要点
│ ├── expert_review_workflow/ # 专家评审工作流
│ ├── expert_review_point_result/ # 专家评审要点结果
│ ├── expert_score_operation/ # 专家评分操作
│ ├── data_flow_log/ # 数据流日志
│ ├── ai_ranking/ # AI 自动排名
│ └── worker/ # Temporal workflows:
│ ├── report/ # 报告生成 workflow
│ └── project/ # 项目同步 workflow

├── agent/ # === AI 评审智能体 ===
│ ├── finance/ # 财务智能评审 (模型/路由/schema/service)
│ ├── performance/ # 业绩智能评审
│ └── worker/ # Agent Temporal Workers

├── tender_agent/ # === 招标文件解析 ===
│ ├── models.py / router.py / schemas.py / service.py

├── temporal/ # === Temporal Workflow 引擎 ===
│ └── tender/ # 招标评审完整工作流
│ ├── workflows.py # Workflow 定义(确定性)
│ ├── activities.py # Activity 定义
│ ├── activity_impl/ # Activity 实现
│ │ ├── document_tree.py # 文档树处理
│ │ ├── expert_points.py # 专家要点
│ │ ├── kv_analysis.py # K/V 分析
│ │ ├── non_technical.py # 非技术评审
│ │ ├── ranking_scoring.py # 排名评分
│ │ ├── task_loading.py # 任务加载
│ │ └── shared/ # 共享模块
│ ├── phased_workflows.py # 分阶段工作流
│ ├── phased_activities.py # 分阶段活动
│ ├── file_tree.py # 文件树工具
│ └── schemas.py # Workflow 输入输出 Schema

├── data_center/ # === 数据中心 ===
│ ├── bid/ # 投标数据
│ ├── tender/ # 招标数据
│ ├── bus/ # 外部招投标平台业务数据
│ ├── experts/ # 专家数据
│ ├── common/ # 公共模块
│ └── worker/ # 数据中心 Worker

├── integrations/ # === 外部集成 (独立 client) ===
│ ├── agent_service/ # AI 智能体服务
│ ├── ai_service/ # AI 通用服务
│ ├── data_center/ # 外部数据中心
│ ├── knowdoc/ # 知识文档
│ ├── knowledge/ # 知识库
│ ├── project_tags/ # 项目标签
│ ├── prompt/ # Prompt 管理
│ └── ragflow/ # RagFlow RAG 系统

├── ums/ # === 统一管理系统 ===
│ ├── api_v1.py # 路由聚合
│ ├── agent_config/ # 智能体配置
│ ├── bid_application/ # 投标申请
│ ├── bid_authorization/ # 投标授权
│ ├── bid_eval_meta/ # 评标元数据
│ ├── bid_eval_stage/ # 评标阶段
│ └── public_trust_platform/ # 公共信用平台

├── image_extract/ # 图片提取
├── statistics/ # 统计分析
├── auth/ # 认证 (API Key / 专家认证)
├── audit_log/ # 审计日志
├── communication/ # HTTP 客户端 (ai_system_client, http_client)
├── police/ # 权限管理
├── utils/ # 通用工具

└── worker/ # Worker 入口 (注册多队列 Worker)
└── main.py

API 路由总览

所有业务路由统一在 /v1/ 前缀下:

前缀模块说明
/v1/project/项目管理项目 CRUD、投标人管理
/v1/report/报告生成预览/生成/下载/删除
/v1/bid-evaluation/投标评审AI 评审、专家评审核心
/v1/ai-ranking/AI 排名投标人智能排名
/v1/point-kv-analysis/要点 K/V 分析评审要点分析
/v1/expert-score-operation/专家评分专家评分操作
/v1/expert-review-workflow/专家评审工作流评审流程编排
/v1/expert-review-point-result/专家要点结果评审要点结果
/v1/project-score-factor/评分因素项目评分标准
/v1/project-unified-review-point/统一评审要点标准化评审
/v1/data-flow-log/数据流日志数据操作审计
/v1/job/Temporal Job通用 Job 管理
/v1/agent/finance/evaluation财务智能评审AI 财务评审
/v1/agent/performance/evaluation业绩智能评审AI 业绩评审
/v1/tender-agent/招标代理招标文件解析
/v1/data-center/bid/投标数据数据中心投标
/v1/data-center/tender/招标数据数据中心招标
/v1/data-center/bus/业务数据外部平台数据
/v1/statistics/统计分析数据统计
/v1/image-extract/图片提取文档图片处理
/v1/ums/统一管理UMS 管理端
/v1/audit-log/审计日志操作审计
/auth/login认证获取 API Key
/health /ready /liveness健康检查服务状态

核心业务流程

1. 招投标评审全流程

招标文件导入


招标文件解析 (tender_agent) ─── Temporal Workflow
│ ├── 文档解析
▼ ├── 图片提取
评分因素配置 ├── RAG 处理
│ └── 要点生成

AI 初评 ─── AI Agent (finance/performance)
├── 财务合规审查
├── 财务数据提取与评分
├── 业绩有效性判断 (RAG)
└── 业绩打分


专家评审 ─── expert_review_workflow
├── 多专家独立评审
├── 多阶段评审 (初评/商务/技术/价格)
├── 报价评分
└── 评审要点填写


评标报告生成 ─── report/ (Temporal + LLM)
├── ReportGenerationWorkflow
├── ContextBuilder → Resolvers (DB/LLM)
├── ReportRenderer (Jinja2)
└── 输出 DOCX → OBS 存储


报告下载 / 归档

2. 评标报告生成 (report/ 模块)

报告生成是体系最完善的子模块,架构如下:

POST /v1/report/generate


ReportController → ReportService
│ ├── 校验报价/评分一致性
│ ├── 检查已存在报告
│ └── 创建 ReportGenerationRun


Temporal Workflow: ReportGenerationWorkflow


Activity: execute_report_generation

├── ReportContextBuilder
│ └── Resolvers:
│ ├── project → 项目信息
│ ├── bidder → 投标人信息
│ ├── result → 评审结果
│ ├── report → 报告配置
│ ├── score → 评分数据
│ └── llm → LLM 动态摘录

├── ReportRenderer
│ └── Jinja2 渲染 Markdown → python-docx 转 DOCX

└── 结果存储
├── OBS 对象存储 (报告文件)
├── ReportRecord (DB)
├── ReportFieldResult (DB, 字段级溯源)
└── LLMCallAttempt (DB, LLM 调用记录)

可追溯设计:每个报告生成都有 RunId,记录每字段来源(DB/LLM)、LLM 调用次数、耗时;删除报告时软删所有关联记录。


Temporal 工作流体系

架构模式

API Service Worker Service
┌──────────────┐ ┌─────────────────────┐
│ Temporal │── start ──▶ │ Workflow Definition │
│ Client (DI) │ │ (deterministic) │
│ │ │ │
│ │ │ execute_activity() │
│ │ │ │ │
│ │ │ ▼ │
│ │ │ Activity: │
│ │ signal/ │ ├── DB I/O │
│ │◀── query ─── │ ├── LLM API call │
│ │ │ ├── OBS/文件系统 │
│ │ │ └── RAG/知识库查询 │
└──────────────┘ └─────────────────────┘

Task Queues

队列名用途
job-task-queue通用 Job、报告生成
tender-review招标评审主流程
tender-ai-heavyAI 密集型评审任务
agent-finance-task-queue财务智能体
agent-performance-task-queue业绩智能体

Temporal 约束

  • Workflow 必须确定性:禁止外部 I/O、datetime.now()、随机数、UUID 生成、环境变量读取、无序集合遍历
  • Activity 是唯一 I/O 入口:所有 DB、HTTP、LLM 调用走 Activity
  • Activity 必须幂等:区分可重试/不可重试错误,heartbeat 上报进度
  • ID 生成:仅在 service/activity 层生成,不在 workflow 内

AI 智能体体系

财务评审 Agent (agent/finance/)

流程说明
财报要点获取LLM 提取财务报表关键点
财报信息抽取结构化数据提取
合规审查财务合规性判断
数据提取 → 代码生成自动生成分析代码
评分过程解释生成评分理由

业绩评审 Agent (agent/performance/)

流程说明
业绩要点生成从投标文件中提取业绩关键信息
RAG 判断 (多轮)基于知识库判断业绩有效性
全文检索判断Full-text 方式辅助判断
业绩打分基于评审标准打分

缓存机制:非技术评审支持 Redis 缓存(NON_TECH_AGENT_CACHE_ENABLED),允许名单 + TTL 控制。


数据库设计原则

  • 禁止外键约束models.py 不使用 ForeignKey
  • 禁止 relationship():应用层处理关联一致性
  • ID 使用 UUID/ULID:不在数据库层生成
  • 逻辑删除:所有表使用 deleted_at 实现软删除
  • 迁移:Alembic 自动生成迁移脚本

统一响应格式

// 成功响应
{
"code": 0,
"message": "success",
"data": { ... }
}

// 分页响应
{
"code": 0,
"message": "success",
"data": {
"items": [...],
"total": 100,
"page": 1,
"page_size": 10,
"total_pages": 10
}
}

// 错误响应
{
"error": "Project not found",
"status_code": 404,
"request_id": "uuid-here"
}

前端 Console

目录结构

console/
├── front/ # Vue 3 前端
│ ├── src/
│ │ ├── views/ # 页面视图
│ │ │ ├── aiAssistedBidEvaluation/ # AI 辅助评标
│ │ │ ├── docEditingAndReview/ # 文档编辑与审查
│ │ │ ├── LogManagement/ # 日志管理
│ │ │ ├── assistant/ # 智能助手
│ │ │ ├── AppCenter.tsx # 应用中心
│ │ │ ├── Login.tsx / Main.tsx # 登录/主框架
│ │ │ └── Setting/ # 设置
│ │ ├── router/ # 路由
│ │ ├── store/ # Pinia 状态管理
│ │ ├── api/ # API 客户端
│ │ └── components/ # 可复用组件
│ ├── vite.config.ts # Vite 配置
│ └── uno.config.ts # UnoCSS 配置

└── bff/ # Node.js/Koa BFF
└── src/
├── index.ts # Koa 入口
├── router/
│ ├── api/ # 后端 API 代理
│ └── proxy/ # 业务代理路由
├── services/ # 业务逻辑
└── utils/ # 工具函数

BFF 代理

BFF 层承担前端请求统一代理:

  • proxy/agent.ts - AI 智能体代理
  • proxy/project.ts - 项目代理
  • proxy/report.ts - 报告代理
  • proxy/technicalReview.ts - 技术评审代理
  • proxy/user.ts - 用户代理
  • api/auth.ts - 认证
  • api/chat.ts - 聊天
  • api/uploadMinio.ts / api/uploadobs.ts - 文件上传

存储层设计

统一存储抽象 (app/core/storage/),支持多 Driver 切换:

Driver说明
local本地文件系统(开发环境)
huawei_obs华为云 OBS
minioMinIO 兼容 S3
alibaba_oss阿里云 OSS

通过 STORAGE_DRIVER 环境变量切换,业务层无感。


配置管理

使用 Pydantic Settings,支持 .env 文件加载。关键配置分组:

  • 环境ENVIRONMENT, DEBUG_SQL, ENABLE_DOCS
  • 数据库ASYNC_DATABASE_URL, DATABASE_URL, REDIS_URL
  • TemporalTEMPORAL_ADDRESS, 多个 TASK_QUEUE
  • LLMLLM_MODEL, VLM_MODEL, VLM_API_BASE
  • AgentAGENT_OPENAI_*, AGENT_*_PROMPT_NAME
  • 外部服务RAGFLOW_API_URL, AIOS_URL, ENERGY_DATA_CENTER_URL
  • 存储STORAGE_DRIVER, STORAGE_BUCKET
  • 报告REPORT_TEMPLATE_PATH, REPORT_TEMPLATE_VERSION
  • 招投标平台BUS_URL, 加解密密钥

Docker 基础设施

docker-compose.yml 定义:

服务镜像端口
PostgreSQLpostgres:16-alpine15432
Temporal Servertemporalio/auto-setup:1.24.27233
Temporal UItemporalio/ui:2.31.28080
Redisredis:7-alpine16379

开发命令

# 安装依赖
make install # uv sync

# 启动数据库 + Temporal
make docker-up

# 数据库迁移
make migrate-auto msg="description"
make upgrade

# 启动 API 服务
make run # uvicorn --reload

# 启动 Worker
make worker # python -m app.worker.main

# 测试
make test # pytest
make test-v # 详细输出
make test-tender-unit # 快速单元测试

# 前端
cd console
yarn dev # 同时启动 front + bff

# 代码质量
make lint # ruff check
make format # ruff format

外部依赖:AIOS 知识服务平台 (aios.ai)

bid.ai 的核心 AI 能力(文档解析、知识提取、Prompt 管理、Agent 编排)依赖 aios.ai 知识服务平台。aios.ai 与 bid.ai 同源于 Litestar 模板,是一套独立的 AI 基础设施服务,提供文档解析流水线、知识库、KV 提取、Agent 管理等能力。

依赖架构

┌──────────────────────────────────────────────────────┐
│ bid.ai │
│ 招标评审 · AI 评标 · 报告生成 · 数据中心 │
│ │
│ ┌──────────────────┐ ┌──────────────────────┐ │
│ │ integrations/ │ │ communication/ │ │
│ │ ├─ knowledge ───┼────┼─▶ AIOS: KV提取 │ │
│ │ ├─ prompt ───┼────┼─▶ AIOS: Prompt管理 │ │
│ │ ├─ knowdoc ───┼────┼─▶ AIOS: 文档解析 │ │
│ │ ├─ agent_service┼────┼─▶ AIOS: Agent管理 │ │
│ │ └─ project_tags┼────┼─▶ AIOS: 项目标签 │ │
│ └──────────────────┘ └──────────────────────┘ │
└────────────────────┬─────────────────────────────────┘
│ HTTP (AIOS_URL)

┌──────────────────────────────────────────────────────┐
│ aios.ai │
│ 文档解析 · 知识库 · KV Agent · LLM 网关 · 标注 │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ knowdoc │ │knowledge │ │ integrations │ │
│ │ 文档解析 │ │ ├─prompt │ │ ├─ dify │ │
│ │ Temporal │ │ ├─kv_agent│ │ ├─ litellm │ │
│ │ + libs │ │ ├─kb │ │ ├─ langfuse │ │
│ │ │ │ └─chunk │ │ └─ ragflow │ │
│ └──────────┘ └──────────┘ └──────────────────┘ │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ agent │ │ llm │ │ libs/ │ │
│ │ Dify管理 │ │ LiteLLM │ │ ├─ knowdoc │ │
│ │ │ │ 网关 │ │ ├─ kv-ragent │ │
│ │ │ │ │ │ └─ knowledge- │ │
│ │ │ │ │ │ trace │ │
│ └──────────┘ └──────────┘ └──────────────────┘ │
└──────────────────────────────────────────────────────┘

bid.ai → aios.ai 调用关系

bid.ai 集成客户端aios.ai 端点用途
integrations/knowledge/client.pyPOST /v1/kv_task/sync招投标文件要点 K/V 提取
integrations/prompt/client.pyGET /v1/prompt/name/{name}获取 AI 评审 Prompt 模板
integrations/knowdoc/client.pyPOST /v1/knowdoc/tasks .../sync文档解析(PDF/Office → Markdown)
integrations/agent_service/client.pyPOST /v1/agents/agent-name GET /v1/agents/{id}获取 Dify 智能体 API Key 及信息
integrations/project_tags/client.pyPOST /v1/tags/by_project获取项目标的物标签
communication/ai_system_client.py(外部招投标平台,非 AIOS)向第三方招投标系统发送状态通知

所有集成 client 的 base_url 均来自 bid.ai 配置的 settings.AIOS_URL(生产环境示例: http://aios-release-knowledge-service-webserver.aios:8000


aios.ai 项目结构

aios.ai/
├── app/ # API 服务
│ ├── main.py # Litestar 入口
│ ├── config.py # Pydantic Settings
│ ├── plugins.py # DatabasePlugin / TemporalPlugin
│ │
│ ├── knowdoc/ # ★ 文档解析引擎(核心依赖)
│ │ ├── router.py # POST /v1/knowdoc/tasks 等
│ │ ├── service.py # 任务编排,指纹去重
│ │ ├── models.py # KnowdocTask 状态机
│ │ ├── runtime/ # 源解析/本地解析/产物发布
│ │ └── worker/ # Temporal Workflow + Activity
│ │
│ ├── knowledge/ # ★ 知识库服务
│ │ ├── knowledge_base/ # 知识库 CRUD
│ │ ├── document/ # 文档管理
│ │ ├── chunk/ # 知识块管理
│ │ ├── kv_agent/ # ★ KV 提取 Agent(bid.ai 核心依赖)
│ │ ├── prompt/ # ★ Prompt 管理(bid.ai 核心依赖)
│ │ ├── image_extract/ # 图片提取
│ │ └── statistics/ # 知识服务统计
│ │
│ ├── agent/ # ★ 智能体管理
│ │ └── service.py # Dify Agent 注册/发布/API Key
│ │
│ ├── llm/ # LLM 模型管理
│ │ └── router.py # LiteLLMController
│ │
│ ├── model/ # 模型管理
│ ├── project/ # 项目管理
│ ├── annotation/ # 标注管理(backflow/trainsets)
│ ├── ums/ # 用户/角色/权限
│ ├── audit_log/ # 审计日志
│ └── auth/ # 认证

├── libs/ # ★ 核心算法库
│ ├── knowdoc/ # 文档解析核心(多格式 → Markdown/JSON)
│ ├── kv-ragent/ # KV 提取 RAG Agent
│ └── knowledge-trace/ # 知识溯源

└── integrations/ # 外部集成
├── dify/ # Dify Workflow
├── litellm/ # LLM 模型代理
├── langfuse/ # 可观测性
├── ragflow/ # RagFlow RAG
├── datacenter/ # 数据中心
└── platform/ # 平台对接

aios.ai 核心模块详述

Knowdoc — 文档解析引擎

文档解析是 bid.ai 的底层依赖:投标文件、招标文件在上 AI 评审前,必须先通过 Knowdoc 解析为结构化 Markdown/JSON。

  • 多格式输入:PDF, DOCX, PPTX, XLSX, OFD, PNG/JPG
  • 多个后端:pipeline(默认流水线)、vlm-transformersvlm-vllm-engine
  • 输入协议:本地文件、HTTP URL、对象存储 URI(obs://, oss://, s3://
  • 异步/同步 API 均支持,指纹去重避免重复解析
  • Temporal 工作流编排,失败自动重试
  • 支持回调通知解析完成
  • 状态机:pending → processing → completed/failed/retrying/cancelled/timeout

Knowdoc 解析流水线

输入文件 (PDF/DOCX/...) → Source Resolver (下载到本地)
→ Local Parser (libs/knowdoc 路由到正确处理器)
→ 处理器链装配与执行
→ Artifact Publisher (上传 Markdown/JSON/Native + 图片)
→ 结果持久化 + 回调

Knowledge — 知识库与 KV 提取

  • KV Agent:bid.ai 最常调用的模块,提供招投标文件的要点提取(Key-Value Pair),用于 AI 评审环节的结构化知识获取
  • Prompt 管理:bid.ai 的 Agent (finance/performance) 通过 PromptClient 从 AIOS 拉取评审 Prompt 模板
  • 知识库:RAGFlow 集成,支持文档入库、分块、检索

Agent — 智能体管理

bid.ai 的 AI 评审 Agent 通过调用 Agent Service 获取 Dify 智能体的 API Key 和配置信息,执行 Dify Workflow 来完成财务/业绩评审。

能力说明
Agent 注册/发布Dify Agent 生命周期管理
API Key 管理为每个 Agent 生成/获取访问令牌
Agent 信息查询通过名称/ID/API Key 查询

Libs — 核心算法库

  • libs/knowdoc:文档解析底层库,包含格式识别、处理器链装配、统一实体层构建
  • libs/kv-ragent:KV 提取的 RAG Agent,结合 LLM 进行结构化信息抽取
  • libs/knowledge-trace:知识溯源能力

配置关系

bid.ai 的 .env 通过 AIOS_URL 指向 aios.ai 服务实例:

# bid.ai .env
AIOS_URL = http://aios-release-knowledge-service-webserver.aios:8000

aios.ai 自身的配置:

# aios.ai .env
TEMPORAL_ADDRESS = localhost:7233
TEMPORAL_NAMESPACE = default
KNOWDOC_TASK_QUEUE = knowdoc-task-queue
KNOWDOC_PIPELINE_TASK_QUEUE = knowdoc-pipeline-task-queue
DIFY_API_URL = https://api.dify.ai
STORAGE_DRIVER = local # 与 bid.ai 共享同一存储抽象层

两项目共享的设计模式

bid.ai 和 aios.ai 共享以下同源设计:

  • 同源模板:均基于 litestar-sqlmodel-template 生成
  • 相同基础设施:Litestar + SQLModel + PostgreSQL + Temporal
  • 相同 DI 模式:DatabasePlugin / TemporalPlugin
  • 相同响应格式:ApiResponse[T]
  • 相同认证体系:API Key (X-API-Key) Guards
  • 相同存储抽象:app/core/storage/ (local/MinIO/OBS/OSS)
  • 相同日志体系:Loguru + Request ID 追踪
  • 相同 API/Worker 分离架构
  • 相同 Worker 入口模式:app/worker/main.py 注册多队列 Worker

面试项目介绍

下面是一段可直接用于面试的 2-3 分钟项目介绍,整合了业务背景、技术架构、个人角色与成果数据。

项目概要卡

维度内容
项目名称国家能源集团智能招评标平台
项目周期2025.07 - 2025.12(6个月)
团队规模15人(后端 6 + AI 4 + 前端 3 + 测试 2)
我的角色系统开发负责人(架构 + AI 应用)
客户国家能源集团

面试话术

背景:国能集团的招评标业务有几个核心痛点。第一,投标文件都是非标文档,每家供应商的格式、排版都不一样,传统规则引擎根本搞不定。第二,合规审查高度依赖人工,专家要逐条核对几百页的招标文件和投标文件。第三,价格计算逻辑复杂,涉及各种公式和评分规则,人工算容易出错。所以集团决定建设一个基于大模型的智能辅助平台。

架构:我设计的是 API / Worker 分离的双服务架构。API 层基于 Litestar 异步框架处理 HTTP 请求和权限校验;Worker 层基于 Temporal 工作流引擎编排所有耗时的 AI 评审任务。我把平台拆成了两个项目——bid.ai 是业务主平台,包含项目管理、AI 评审、报告生成;aios.ai 是 AI 基础设施层,提供文档解析、知识库、Prompt 管理、模型代理。两者通过 HTTP 解耦,这样 AI 基础设施可以独立迭代,不影响业务侧。

文档解析:投标文件最大的问题是"非标"。我设计了一套多模型协同的文档解析流水线——用 MinerU 做版面分析,用 Qwen-VL 做图片表格识别,用 Knowdoc 把各种格式统一转为结构化 Markdown。这样不管输入是扫描件、PDF 还是 Office 文档,下游拿到的都是干净的结构化数据。

AI 评审:评审环节我做了 Master-Slave 多智能体协作。一条线是财务评审 Agent,做财报合规审查、数据提取和自动评分;另一条是业绩评审 Agent,基于 Agentic RAG 做业绩有效性判断——我把评审标准和历史案例作为静态知识库,临时上传的投标文件作为动态知识,动静分离兼顾了准确性和灵活性。价格计算场景我单独设计了一个 Code Agent,Agent 根据评分公式自动生成 Python 代码执行计算,而不是硬编码规则——这个很关键,因为不同项目的评分规则天差地别。

工作流引擎:为什么选 Temporal 而不是 Celery?招投标评审一个流程可能跑几个小时甚至更久,中间任何节点都可能失败。Temporal 天然支持断点续跑和状态恢复。评审过程经常需要人工介入——AI 初评完,专家要复核修改,Temporal 的 Signal 机制可以优雅处理这种人机交互。Activity 级别还可以配置精细的重试策略和超时控制。Temporal 的约束也倒逼了架构设计——Workflow 必须确定性,所有外部 I/O 封在 Activity 里,保证即便 Worker 宕机也能完美恢复。

模型与可观测:模型层我基于 LiteLLM 搭了统一的模型代理网关,上层业务不直接感知底层模型。挂了 Qwen3 系列多个规格的模型,简单分类用小模型,复杂推理用 30B 甚至 70B。可观测方面接了 Langfuse,做 LLM 调用追踪和效果评估,支持对每次评审的 Prompt 输出做对比分析,持续优化 Agent 效果。

成果:最终单台 8×4090 的服务器,4 小时内完成从文档清洗、入库到 AI 评审的全流程。评标专家工作量降低 30% 以上,整体评标费用下降 25% 以上。平台已通过验收,具备在集团内部推广的条件。

关键技术选型决策

决策点选择替代方案理由
工作流引擎TemporalCelery / Airflow断点续跑 + 状态恢复 + 人机交互 Signal
AI 编排Master-Slave Agents单 Agent 全链路并行执行、专业分工、独立迭代
模型代理LiteLLM直接调用 API统一网关、模型热切换、监控接入
知识库Agentic RAG 动静分离单一向量库静态法规 + 动态项目资料隔离召回
文档解析MinerU + Qwen-VL + Knowdoc单一 OCR多模型协同处理复杂版面
可观测Langfuse自建日志LLM 调用追踪 + Prompt 效果评估开箱即用
包管理uvpip / poetry解析速度快 10-100x,依赖锁定可靠

与常规方案的核心差异

维度常规做法本项目做法
任务队列Celery / Redis Queue(无状态)Temporal(有状态 + 断点续跑)
AI Agent单 Agent 串行Master-Slave 多 Agent 并行
评分配置硬编码规则引擎Code Agent 动态生成执行
文档解析单一 OCR 方案MinerU + VLM 多模型协同流水线
知识召回单一向量库检索Agentic RAG 动静分离
模型调用直连模型 APILiteLLM 统一代理网关
可观测应用日志Langfuse 全链路 LLM 追踪

面试问答实录

问题一:简述一下整个评标流程,其中的难点有哪些

整个评标流程我分成五个阶段:

第一阶段:项目启动与配置。 招标方在平台创建项目,配置评分因素和评审阶段。评分因素包括商务、技术、价格等维度,每个维度下有二级评分指标和对应的分值权重。这个阶段的难点是评分模型灵活性问题——不同项目的评分规则差异很大,必须支持动态配置,不能硬编码。

第二阶段:招标文件解析。 上传招标文件后,Temporal 触发解析 Workflow,自动识别文件格式,调用文档解析引擎(Knowdoc)把 PDF、Word 等转为结构化 Markdown。然后基于招标内容提取评审要点,生成每个评分因素对应的考察点。这个阶段的主要难点是非标文档处理——扫描件、盖章页、图片表格混合排版,单一 OCR 搞不定,我们用了 MinerU + Qwen-VL 多模型协同流水线来解决。

第三阶段:AI 初评。 投标人上传投标文件后,系统自动解析,然后启动多个 AI Agent 并行评审。财务 Agent 做财报合规审查、数据提取和评分;业绩 Agent 基于 Agentic RAG 判断业绩有效性并打分。Agent 会把评审结果回写到数据库,同时留痕——每项评分都有来源依据。这个阶段的核心难点是 Agent 的准确性问题——大模型可能幻觉,我们通过 RAG 注入评审标准约束输出,同时设计了人工复核兜底机制。

第四阶段:专家复核。 AI 初评完成后,专家登录系统复核每一项评分。专家可以认同 AI 结果、修改分数或驳回重评。我们通过 Temporal 的 Signal 机制处理人机交互——专家修改触发 Signal,Workflow 感知到变更后更新状态。难点在于多人协作的数据一致性问题——同一项目有多个专家同时评审,必须确保各专家独立打分、互不干扰,最后还是可汇总的。

第五阶段:报告生成。 所有评审完成后,触发报告生成 Workflow。系统收集项目信息、各阶段评审结果、评分明细,通过 LLM 动态摘录关键评语,填充到 Markdown 模板中,再用 python-docx 转成 DOCX 上传到 OBS。这个阶段的难点是报告的可追溯性——每项数据必须能溯源到来源,我们通过 ReportGenerationRun + ReportFieldResult + LLMCallAttempt 三张表做了完整的调用链路记录。

总结核心难点,我认为有三点:

  1. 非标文档的标准化——这是 AI 评审的前提,格式不统一后续处理无从谈起
  2. AI 准确性与可控性——评审是严肃业务,幻觉不可接受,必须有 RAG 约束和人工兜底
  3. 长流程的可靠性——一个评审流程可能跑几小时甚至跨天,必须保证任何节点失败都能恢复,这就是我选 Temporal 而不是 Celery 的根本原因
追问:怎么使用 MinerU + Qwen-VL 提高 OCR 能力的

这个问题我分三层来讲:为什么需要组合、怎么组合、以及效果。

为什么需要组合?

招投标文档有一个很头疼的问题——它不是干净的电子文档,而是各种来源的混合体。有直接生成的 PDF,有扫描件,有盖章页,有图片表格,甚至有些是拍照插入的。单一方案搞不定所有场景。

MinerU 的优势是版面分析能力很强——它能识别标题、段落、表格、页眉页脚这些结构元素,而且对纯文本 PDF 的提取精度很高。但它的弱项是面对图片类内容——扫描件、盖章遮挡的文字、复杂图表——提取效果会明显下降。

Qwen-VL 的优势正好反过来——它是视觉语言模型,能"看懂"图片里的文字和结构。但它不适合整本几百页的文档逐页处理,成本太高,速度太慢。

所以思路很明确:让 MinerU 做粗粒度的结构化提取,让 VLM 做细粒度的视觉补位。

具体怎么组合的?

我们设计了一个"先拆分、再路由、后合并"的流水线:

第一步,MinerU 对整份文档做全量解析,输出结构化 Markdown,同时标记出它"不确定"的区域——比如置信度低的文本块、识别失败的图片区域、疑似表格但结构混乱的部分。这些区域会被裁剪成独立图片,进入 VLM 处理队列。

第二步,Qwen-VL 针对这些"疑难图片"做专项识别。我们不是简单地调一个接口,而是做了场景化 Prompt 设计:表格类用表格识别 Prompt,盖章遮挡类用"忽略印章、提取底层文字"的 Prompt,手写体用手写识别 Prompt。VLM 的输出是结构化的文本,而不是笼统的描述。

第三步,把 VLM 识别结果合并回 MinerU 的原始结构中,覆盖低置信区域。这里有个关键设计——我们不做简单的字符串替换,而是保持原来的 Markdown 结构骨架,只替换内容节点。这样输出始终是结构化的,下游可以直接用。

技术实现细节:

在代码层面,这个流水线由 Knowdoc 引擎编排。配置上我们支持多个 backend:

  • pipeline:默认流水线,MinerU 做主力解析
  • vlm-vllm-engine:纯 VLM 模式,用于特殊场景
  • vlm-http-client:远程 VLM 服务调用

实际跑的时候,还会走一个"图片后处理"路径——文档解析完成后,image_extract 模块会对文档中提取出的图片做二次识别,特别是那些带有复杂表格和数据的图片。

效果如何?

最直接的效果是,之前 MinerU 单独处理时,扫描件 + 盖章页的字段提取准确率大概在 70% 左右——盖章一挡,关键信息就丢了。加了 VLM 补位后,准确率提升到 90% 以上。特别是价格表格这类关键数据,之前经常因为表格线不清晰导致解析错位,VLM 的视觉理解能力很好地解决了这个问题。

另外有个实际收益——因为不需要对整份文档做 VLM 推理,只处理 MinerU 标记的低置信区域,单份 200 页标书的处理时间只增加了 15-20%,但准确率提升了十几个百分点,性价比非常高。

总结一句话:MinerU 负责"量"——快速结构化整本文档,Qwen-VL 负责"质"——精准识别疑难区域,两者通过置信度阈值和场景路由组合在一起。

追问:第三阶段 AI 初评的 RAG 是怎么构建的

这个问题我分几个层面来讲:RAG 的架构设计、知识库的构建、检索策略、以及整个流程的编排。

一、架构设计:动静分离

RAG 构建的核心思路是"动静分离"——把知识分为静态和动态两类,用不同的策略处理。

静态知识:包括评审标准、行业法规、历史评分案例。这些信息是长期有效的,不会因为项目变化而改变。我们在 RagFlow 中建了专门的知识库,把法规文档、评审标准手册一次性入库、分块、embedding,构建持久化的向量索引。

动态知识:包括当前项目的招标文件、各家投标人上传的投标文件。这些是每个项目独有的,项目结束之后就没有保留价值了。动态知识的处理流程是:文档先经过 Knowdoc 解析为结构化 Markdown,然后按需走 KV 提取或者直接全量喂给 Agent。

动静分离的好处很明显——静态知识的检索范围稳定,embedding 可以离线做,响应快;动态知识按项目隔离,不会互相干扰,也方便项目结束后清理。

二、知识库的构建流程

具体来说,一个项目从开始到 AI 初评,知识的构建顺序是这样的:

项目创建后,上传招标文件。招标文件经过 Knowdoc 解析,产出结构化 Markdown。然后调用 KV Agent 进行要点提取——把几百页的招标文件提炼为关键的评审要点和评分因素。

投标人上传投标文件后,同样经过 Knowdoc 解析。然后业绩评审 Agent 把投标文件内容和招标要求做匹配——这里 RAG 就派上用场了。

业绩评审的场景最有代表性。它的判断逻辑是:投标人声称自己有某个业绩,系统需要判断这个业绩是否满足招标文件的要求。这个判断不能光靠 LLM 的常识,必须基于招标文件中明确的评判标准。所以我们这样设计:

第一,从 aios.ai 的 Prompt 管理服务中拉取专门的评审 Prompt 模板,这些 Prompt 模板本身就已经嵌入了评审规则和判断逻辑。

第二,通过 RAG 检索招标文件中对应的评审条款和评分标准,作为参考上下文注入 Agent。

第三,Agent 对投标文件的业绩描述和招标标准做比对,输出"通过/不通过"的判断和评分理由。

三、多轮判断策略:Split → Rethink

业绩评审 RAG 比较有特色的设计是多轮判断。我们不是问一次 LLM 就完事,而是做"先分后合"的模式:

第一轮叫 Split(拆分判断)。系统把投标人的多个业绩拆开,逐个判断每个业绩是否有效。这一轮 RAG 检索的上下文是招标文件中对应的具体条款。

第二轮叫 Rethink(反思复核)。把第一轮的判断结果汇总,再次检索更广泛的上下文,让 LLM 做一次全局审视——有没有漏判的?有没有误判的?前后判断是否一致?

如果 RAG 检索到的内容不足以支撑判断(比如某些技术细节在招标文件中写得很模糊),系统会降级到 Full-text 模式——直接对整份文档做关键词检索和全文匹配,作为补充信息源。

从代码配置可以看到,我们为每个环节都配置了独立的 Prompt:

  • AGENT_PERFORMANCE_JUDGMENT_RAG_SPLIT_PROMPT_NAME — RAG 拆分判断的 Prompt
  • AGENT_PERFORMANCE_JUDGMENT_RAG_RETHINK_PROMPT_NAME — RAG 反思复核的 Prompt
  • AGENT_PERFORMANCE_JUDGMENT_FULLTEXT_SPLIT_PROMPT_NAME — 全文检索拆分 Prompt
  • AGENT_PERFORMANCE_JUDGMENT_FULLTEXT_RETHINK_PROMPT_NAME — 全文检索反思 Prompt

每一对都是"先拆分判断、再反思复核"的模式。

四、缓存与性能

我们还做了缓存层。对于评审中频繁出现的标准条款(比如"报价评分"这类每个项目都有的评审项),如果命中缓存直接返回结果,不需要重复调用 LLM。配置上通过 NON_TECH_AGENT_CACHE_ENABLED 控制开关,NON_TECH_AGENT_CACHE_ALLOWLIST 指定缓存的白名单,NON_TECH_AGENT_CACHE_TTL_SECONDS 控制过期时间。

五、数据流总结

所以整个 RAG 的数据流是:

招标文件 → Knowdoc 解析 → 结构化 Markdown

KV Agent 要点提取 → 评审要点库(静态)

投标文件 → Knowdoc 解析 → 结构化 Markdown

Agentic RAG 判断流水线:
├── Split: 逐个判断(RAG 检索招标条款)
├── Rethink: 全局复核(更广泛上下文)
├── Fallback: Full-text 检索(RAG 不足时)
└── Cache: 命中白名单直接返回

输出:通过/不通过 + 评分 + 理由 + 引用来源

这样设计的好处是:每个判断都有依据可追溯,评审结果是可解释的,不是 LLM 的"黑盒"输出。而且通过多轮判断 + 反思机制,有效降低了幻觉率。

问题:Knowdoc 是什么,解决什么问题,怎么设计的

Knowdoc 是什么?

Knowdoc 是 aios.ai 项目中的文档解析引擎,也是 bid.ai 整个 AI 评审流水线的底层基础设施。所有进入评审流程的文档——不管是招标文件、投标文件还是资质证明——都要先经过 Knowdoc 解析,把非结构化文档转为结构化数据,下游才能做 KV 提取、RAG 检索和 AI 评审。

解决什么问题?

核心问题就一个:招投标领域的文档是非标的,但 AI 需要标准化的输入。

具体来说有三个层面:

第一,格式多样化。输入可能是 PDF、Word、Excel、PPT,甚至是 OFD(国标版式文件)和图片扫描件。每种格式的解析方式完全不同,业务层不可能为每种格式单独写适配。

第二,内容非标。同样是 PDF,有的直接是电子生成的(文本层清晰),有的是扫描件(只有图片层),有的混合了图片和文字。传统方案要么只处理文本 PDF,要么全量 OCR,效果都不好。

第三,结构保留。解析不能只把文字提取出来就完事,必须保留文档的层次结构——标题、段落、表格、图片的位置关系。因为下游的 KV 提取和 RAG 检索需要知道"这段文字是哪个章节的"。

怎么设计的?

Knowdoc 的设计分两层:app 层(编排)libs 层(算法),职责严格分离。

app 层(app/knowdoc/ 负责任务的编排与发布。它不做解析,而是做四件事:

  1. 暴露 REST API(创建任务、查询进度、同步/异步执行)
  2. 管理任务状态机和去重指纹
  3. 解析输入协议并下载到本地文件
  4. 上传解析结果并发送回调

libs 层(libs/knowdoc/ 负责实际的解析算法。它不处理网络、不处理文件下载、不上传结果,只做一件事:把本地文件解析为结构化文档

这个分层设计的一个关键好处是:算法库可以在没有网络、没有数据库的纯本地环境跑,方便测试和调试。

运行流程:

调用方 POST /v1/knowdoc/tasks

KnowdocService 创建任务,生成占位指纹

Temporal Workflow: KnowdocWorkflow.run(task_id, params)
├── Activity 1: validate_input(校验参数)
├── Activity 2: process_document_pipeline(核心流水线)
│ ├── resolve_source_to_local:解析协议,下载到本地
│ │ └── 支持 file://、http://、obs://、oss://、s3://
│ ├── 计算内容指纹 → 命中历史结果直接复用
│ ├── run_local_pipeline:调用 libs/knowdoc 做实际解析
│ │ └── 内部做格式识别 → 路由到正确处理器链
│ └── publish_artifacts:上传 Markdown/JSON/Native + 图片
└── Activity 3: finalize_task(持久化结果,回调)

支持的后端:

  • pipeline:默认流水线,MinerU 做主力 PDF 解析
  • vlm-transformers:HuggingFace transformers 调 VLM
  • vlm-vllm-engine:通过 vLLM 部署的 VLM 服务
  • vlm-http-client:远程 VLM HTTP 服务

支持的输入格式:

  • PDF, DOC/DOCX, PPT/PPTX, XLS/XLSX, OFD, PNG/JPG/JPEG

状态机:

pending → processing → completed
→ failed
→ retrying
→ cancelled
→ timeout

状态转换有严格的校验规则,非法转换会被拦截并记录 warning。

指纹去重:

同一个文件如果入参完全一样(processors、format、backend、knowdoc-pdf 版本、文件 sha256),直接复用历史结果,不需要重新解析。生产环境下指纹命中的缓存命中率在 30% 以上,节省了大量计算资源。

总结:Knowdoc 定位就是 AI 评审的"预处理层"——把各种乱七八糟的文档变成统一的结构化数据。它不解决评审问题,但它是评审的前提。

问题:Knowdoc 的 schema 怎么设计的

Schema 设计分了五层:请求层、任务持久化层、事件审计层、指纹层、结果层。每一层解决不同的问题。

一、请求层:KnowdocTaskCreate

这是调用方传入的参数结构,核心字段是 params

  • params.filepath:文件路径或 URL(必填)。支持多种协议:本地路径、http://obs://oss://s3://
  • params.backend:解析后端,可选 pipeline | vlm-transformers | vlm-vllm-engine | vlm-http-client
  • params.format:输出格式,markdownjson
  • params.processors:处理器列表,控制流水线中跑哪些处理步骤
  • params.return_markdown_content:布尔值,是否在响应中直接返回 markdown 正文(免去二次下载)
  • force:布尔值,是否跳过去重
  • timeout_seconds:活动超时时间
  • callback_urlcallback_auth_tokencallback_eventscallback_headers:回调配置

设计上有个细节:params 作为 JSON 存数据库,而不是展开成独立字段。因为处理参数未来可能扩展,展开的话每次加字段都要改表结构,不灵活。

二、任务持久化层:knowdoc_task 表

这是核心实体,记录每个解析任务的完整信息:

  • id:UUID 主键
  • status:状态字符串(小写),受严格的状态机约束
  • params_json:请求参数的 JSON 序列化
  • fingerprint:去重指纹
  • result_json:解析结果
  • error_message:失败原因
  • progress:进度百分比(0-100)
  • callback_urlcallback_auth_token:回调配置
  • created_atupdated_atstarted_atcompleted_at 时间戳
  • workflow_idrun_id:Temporal 工作流标识

状态机的流转规则定义在模型层的 VALID_TRANSITIONS 字典里,每个状态显式声明了允许的下一个状态。服务层每次更新状态前都会做校验,非法转换会被拦截并记录 warning。这个设计是为了防止并发场景下的状态混乱。

三、事件审计层:knowdoc_task_event 表

每次状态变更都会写一条事件记录,类似 event sourcing 的思路:

  • id:主键
  • task_id:关联任务
  • from_statusto_status:变更前后的状态
  • message:变更原因
  • created_at:发生时间

这个表的设计意图是:生产环境出了问题,不需要查日志,直接查这个表就能还原任务的完整生命周期——哪个环节失败了、失败了几次、每次失败的原因是什么。

四、指纹层:Fingerprint

指纹不是一张表,而是一个计算出来的哈希值,用在去重场景。

指纹载荷包含:

  • processors(排序后)
  • format
  • backend
  • knowdoc-pdf 版本
  • file_sha256(文件内容哈希)

这里有个重要的设计细节:指纹分两阶段计算。任务创建时先生成一个"占位指纹"(不依赖文件内容);文件下载到本地计算出 sha256 后,再更新为"内容指纹"。这么设计是因为文件下载可能比较耗时,如果等下载完才创建任务,调用方等待时间太长。有了占位指纹,接口可以快速返回 task_id,后续异步完成。

查询去重时,只有状态为 completed、指纹完全一致、且三类结果产物(markdown、native、json)都完整的任务才会被复用。

五、结果层:解析产物

解析完成后,结果写入 result_json,核心字段:

  • markdown:Markdown 格式结果的 URL(存储在 OBS/MinIO/本地)
  • native:原生格式结果的 URL
  • json:JSON 格式结果的 URL
  • markdown_content:按需返回的 markdown 正文内容
  • processors:实际执行的处理器列表
  • processing_time:解析耗时
  • metadatas:元数据信息(页码、文件大小等)

产物 URL 的格式取决于存储驱动:file://(本地)、obs://bucket/key(华为 OBS)、oss://bucket/key(阿里 OSS)、s3://bucket/key(MinIO)。

为什么这样分层?

核心考虑是查询模式不同。任务表用来做列表查询和状态轮询,字段精简、索引明确;事件表用来做排查,写多读少;结果 JSON 通过 app 层 API 返回,不直接在数据库层面关联查询。如果所有东西塞一张大表,索引难设计,查询性能也受影响。

问题:评标分技术、商务、价格等,每一项要求不同,每个评分点都设计单独的 Agent 来评分吗

不是每个评分点单独一个 Agent,那样维护成本太高了。我们的设计是两层抽象:按评审域划分 Master Agent + 评分因素驱动

第一层:按评审域划分 Master Agent

我们把评分点归到几个评审域,每个域对应一个 Master Agent:

  • 财务评审 Agentagent/finance/):负责所有跟财务报表演进的评分项。合规审查、数据提取、评分,都在这个 Agent 里完成。
  • 业绩评审 Agentagent/performance/):负责所有跟公司业绩相关的评分项。业绩有效性判断、打分,都在这里。
  • 技术评审 Agent:负责技术方案的评审,这块是我们用 Dify Workflow 编排的,具体每个技术评分点通过 Dify 的工作流来执行。

之所以这样划分,是因为同一个域的评分点之间是有逻辑关联的——财务数据提取的结果可以直接复用到合规审查和评分环节,没必要切成多个 Agent 分别调一遍 LLM。

第二层:评分因素驱动

每个项目在启动阶段会配置 ProjectScoreFactor(评分因素)表。每条记录定义了一个评分项:

  • 它属于哪个阶段(商务/技术/价格)
  • 它的评分标准(standard 字段)
  • 它的分值范围(min_scoremax_score
  • 它的评分公式(score_formula
  • 它关联的 Agent 配置(agent_config_id

AI 评审时,Master Agent 遍历当前域下的所有评分因素,逐项评审。但不是简单的 for 循环串行——我们通过 Temporal 做了并行控制,同一个项目下多个评分因素可以并行处理,用 tender-ai-heavy 队列控制并发度。

具体执行路径:

ProjectScoreFactor 配置
├── stage_name: TECHNICAL_REVIEW
├── factor_name: 技术方案完整性
├── standard: 评委评分标准...
└── agent_config_id: tech-review-agent

评审时:
Temporal Workflow 遍历该项目的评分因素

按 agent_config_id 分组

每组路由到对应的 Dify Workflow / Agent

Agent 拉取 Prompt + RAG 检索 → 输出评分 + 理由

写入 BidEvaluation 表 (AI 评审结果)

专家复核阶段可见 AI 初评结果

那"价格评审"呢?价格比较特殊

价格评审我们没有走 LLM Agent,而是单独设计了一个 Code Agent。原因是价格评分通常是公式驱动的——比如"报价低于基准价 1% 扣 0.5 分"。这种计算用 LLM 反而容易出错,而且结果不可精确复现。

我们的做法是:Agent 根据评分公式自动生成 Python 代码,在沙箱环境执行计算,输出精确的分数。这样既保留了灵活性(不同项目的公式不同),又保证了精确性。

为什么不每个评分点一个 Agent?

三个原因:

第一,成本问题。每个 Agent 独立调用 LLM 意味着 token 消耗线性增长。把同域的评分点合并到同一个 Agent 会话里,上下文可以复用,评审结果也可以互相引用。

第二,一致性问题。如果财务合规和财务数据提取是两个 Agent,可能出现合规说"通过"但数据提取发现材料缺失的矛盾情况。在同一个 Agent 里做,可以全局判断。

第三,维护问题。评分因素本身是动态配置的——一个项目可能有几十个评分因素,每个季度还可能调整。如果每个评分点对应一个 Agent,Agent 数量和 Prompt 的管理就会变成噩梦。我们通过 agent_config 把 Agent 类型和评分因素解耦,新增一个评分点只需要配数据库,不需要改代码。

所以最终架构是三层路由

评分因素(几十个)
↓ 按 agent_config 分组
评审域 Agent(3-4 个 Master Agent)
↓ 按场景路由
执行后端:
├── Dify Workflow(技术评审等)
├── Python Code Agent(价格计算)
└── Direct LLM Call(简单判断)
问题:有使用多 Agent 吗,多 Agent 怎么通信

有多 Agent,但我们的多 Agent 不是"Agent 之间直接对话"那种模式,而是通过 Temporal Workflow 编排 + 数据库共享状态的间接通信模式。

有哪些 Agent?

按职责分,有三类 Agent:

评审域 Master Agent:

  • 财务评审 Agent — 做财报合规审查、数据提取、评分
  • 业绩评审 Agent — 基于 Agentic RAG 判断业绩有效性并打分
  • 技术评审 Agent — 技术方案评估(Dify Workflow 编排)

辅助 Agent:

  • Code Agent — 价格公式计算,动态生成 Python 代码执行
  • KV Agent — 文档要点提取,把非结构化内容转为 Key-Value 对

基础能力 Agent(aios.ai 侧):

  • Knowdoc Agent — 文档解析(MinerU + VLM 多模型协同)
  • RAG Agent — 知识检索与判断

Agent 之间怎么"通信"?

我们用了三种模式,按场景选:

模式一:Temporal Workflow 编排(最核心的方式)

这是最主要的通信方式。Agent 之间不直接调用,而是由 Temporal Workflow 负责编排和执行顺序。举个例子:

TenderEvaluationWorkflow
├── Activity: parse_documents(文档解析)
│ └── 内部调用 Knowdoc Agent
├── Activity: finance_evaluation(财务评审)
│ └── 内部调用财务 Agent + RAG Agent
├── Activity: performance_evaluation(业绩评审)
│ └── 内部调用业绩 Agent + RAG Agent
├── Activity: technical_evaluation(技术评审)
│ └── 内部调用技术 Agent(Dify Workflow)
├── Activity: price_evaluation(价格计算)
│ └── 内部调用 Code Agent
└── Activity: generate_report(报告生成)
└── ReportAgent

关键设计是:这些 Activity 是并行执行还是串行执行,由 Workflow 代码决定,而且可以随时调整。财务和业绩评审没有依赖关系,就并行跑;报告生成依赖所有评审结果,就等前面都完成再跑。

Workflow 在这里的角色就是"总指挥",它不干活,但它知道谁先谁后、谁依赖谁。

模式二:数据库共享状态

Agent 之间不直接传数据,而是通过数据库读写来共享信息:

  • 文档解析 Agent 把解析结果写到 TenderTask
  • KV Agent 读取解析结果,提取要点,写到 PointKVAnalysis
  • 评审 Agent 读取要点数据 + 原始文档,做评分,写到 BidEvaluation
  • 报告生成 Agent 读取所有评审结果,生成报告

每个 Agent 只管自己的一亩三分地:读上游写好的数据,做自己的处理,把结果写下去。这样 Agent 之间完全解耦,任何一个 Agent 的 Prompt 调整或模型替换都不会影响上下游。

模式三:Dify Workflow 内部多步骤(Agent 内部的 Agent)

有些评审场景本身就需要多步推理。比如技术评审,一个 Dify Workflow 内部可以串多个 LLM 节点:先做文档分类,再做内容抽取,再做合规判断,最后评分。这些节点在 Dify 内部通过变量传递数据,对上层 Workflow 来说就是一个黑盒 Activity。

为什么不走 Agent 直接通信(比如函数调用那种)?

我们刻意避免了 Agent 之间直接调用。原因有三:

第一,可恢复性。招投标评审是长流程,如果一个 Agent 调另一个 Agent,中间任何一个环节断了(比如 Worker 重启),整个调用链就断了。Temporal Workflow 的断点续跑只能恢复 Workflow 级别的编排,恢复不了 Agent 之间已经发出的 HTTP 调用。通过数据库共享状态,每个 Activity 都是幂等的、可重入的。

第二,可观测性。Agent 直接通信就像一个分布式调用链,出了问题要排查"是哪个 Agent 传了什么错数据给哪个 Agent"。数据库共享状态就清楚多了——读一下 BidEvaluation 表,就知道这个评分是谁评的、用了什么数据、什么时候评的。

第三,可调试性。开发阶段,我们可以直接往数据库写模拟数据来测试下游 Agent,不需要启动上游 Agent。调试单个 Agent 的时候也不用担心把上下游搞坏了。

总结:我们确实用了多 Agent,但走的是"Workflow 编排 + 数据库共享"的间接通信模式,而不是 Agent 之间直接对话。好处就是长流程可靠、逻辑可追溯、单点可调试。

问题:怎么控制可靠性的,比如某一步骤失败,或者 LLM 报错

这个问题分几个层面来讲:Temporal 的失败恢复、LLM 调用的容错策略、以及兜底的人工复核。

第一层:Temporal 的 Retry + 断点续跑

先从时序图里的 ActualTenderWorkflow 说起。每一个 Activity 都配置了 Retry Policy:

# Temporal activity 配置
@activity.defn
async def parse_and_split_file(...):
# 文档解析逻辑

# Worker 启动时注册的 retry 配置
retry_policy = RetryPolicy(
initial_interval=5, # 首次重试等待 5 秒
backoff_coefficient=2.0, # 指数退避,每次翻倍
maximum_interval=60, # 最长间隔 60 秒
maximum_attempts=5, # 最多重试 5 次
non_retryable_error_types=["InvalidArgumentError", "FileNotFoundError"]
)

具体策略是:

  • 网络抖动/LLM 限流:用默认的指数退避重试。LLM API 返回 429 或 503 时,Activity 自动重试,对上层 Workflow 透明。
  • 非法参数/文件不存在:标记为 non_retryable,直接失败。因为重试也不会让文件凭空出现。
  • Worker 宕机:Temporal Server 感知到 Worker 心跳超时后,自动把 Activity 调度到其他 Worker 执行。因为是幂等设计(结果写数据库后会跳过),重复执行也不会产生副作用。
  • 整个 Workflow 中断:Temporal Server 持久化 Workflow 状态,服务恢复后自动从上次中断点继续执行。

第二层:LLM 调用层面的容错

LLM 调用的失败模式和常规 HTTP 请求不一样,我们做了针对性处理:

1. 模型级别兜底(Fallback Model)

这是我们最关键的容错手段。每个 LLM 调用都配置了模型降级链:

# 实际配置
primary: deepseek-chat
fallback1: claude-3-haiku
fallback2: qwen-turbo

当主模型返回错误(超时、上下文溢出、内容审核拒绝),自动切换到降级模型重试,整个过程对 Activity 逻辑透明。实现上通过 LiteLLM 的 router 配置做的:

# LiteLLM router config
model_list = [
{
"model_name": "deepseek-chat",
"litellm_params": {"model": "deepseek/deepseek-chat", "rpm": 100},
},
{
"model_name": "deepseek-chat",
"litellm_params": {"model": "claude-3-haiku", "rpm": 200},
},
]
router = Router(model_list=model_list)

Router 配置了 rpm 限流参数,超过速率上限会自动等待或切换。

2. 上下文溢出(Context Window)处理

投标文件普遍很长,尤其是标书的技术方案部分。我们的策略是:

LLM Call
↓ 如果 4001 (context length exceeded)
重试: 启用摘要模式
↓ 对文档进行分段摘要
↓ 把摘要拼接后再次调用
↓ 如果还是超长
再重试: 分层摘要(先按章节摘要 → 再合并摘要)
↓ 用压缩后的内容评分

这个逻辑封装在 aios.ai 的 LLM gateway 层,Review Agent 不需要关心。

3. JSON Parse 失败重试

Agent 评审结果用 JSON 格式输出({score, reason, evidence}),但 LLM 偶尔会输出残缺 JSON。我们在 Agent Prompt 里加了两层保障:

  • 第一层:Prompt 里明确要求 You MUST output valid JSON. No other text.,并在 System Prompt 里给了严格的 JSON Schema 示例
  • 第二层:Agent 代码层面的 Parse → 失败 → 重试 Prompt("你的上次输出 JSON 格式有问题,请重新输出"),最多重试 3 次

如果 3 次都失败,Activity 标记为"部分失败"并返回错误详情,而不是整个流程中断。

4. 空结果/低置信度处理

Agent 可能返回"无法判断"或置信度特别低的结果。我们的做法是:不把它当失败处理,而是记录低置信度标记。这样评审专家在复核页面可以看到醒目的"AI 评分置信度低,请重点关注"标记,知道需要人工介入。

第三层:数据库状态机 + 人工兜底

不管 Temporal 重试还是 LLM 降级,如果最终还是失败了呢?我们有一层兜底:

评估任务状态转换:

PENDING → PROCESSING → COMPLETED
↘ FAILED
↘ PARTIAL_FAILED

三个关键设计:

1. 显式 FAILED 状态

Activity 在重试用尽后不会静默失败,而是把相关的 BidEvaluation 记录标记为 FAILED,同时写入 error_messageerror_stack 字段。Workflow 感知到失败后,继续执行其他不依赖此结果的活动,而不是整个流程回滚。

比如财务 Agent 失败了,技术评审和业绩评审照常进行。最终生成的报告里会标注"财务评审因 xx 原因未完成,需人工补充"。

2. 人工复核兜底

Temporal Workflow 支持 Signal 机制,我们利用这个做了人机交互:

Workflow 进入 REVIEW 状态
↓ 等待 Signal(专家确认/修改分数)
↓ 收到 Signal → 更新 BidEvaluation → 继续后续流程

专家在 Console 页面可以看到每个评分项的 AI 结论和来源依据,选择"通过"或"修改"。

如果 AI 评审步骤失败,Workflow 进入 AWAITING_MANUAL 状态,通知专家全人工评审该项目。

3. 超时保护

每个 Activity 和整个 Workflow 都配置了超时:

  • Activity 级别:start_to_close_timeout=10min(单个 LLM 调用超过 10 分钟算超时)
  • Workflow 级别:execution_timeout=24h(整个评审流程超过 24 小时自动终止并通知管理员)

超时后走同样的 FAILED → 人工兜底路径。

场景走一遍

假设一个投标人上传了投标文件:

1. 文档解析 Activity
→ MinerU 解析失败(文件损坏)
→ Retry 3 次均失败
→ Knowdoc 标记 task FAILED
→ Workflow 收到失败,跳过 AI 评审,直接进入"人工处理"状态

2. 财务评审 Activity
→ LLM 调用返回 429(限流)
→ LiteLLM 自动切换到 fallback 模型
→ 重试成功,正常出结果

3. 业绩评审 Activity
→ JSON Parse 失败
→ Agent 重试 2 次后成功
→ 但置信度只有 0.3
→ 结果标记 low_confidence
→ 专家复核页面高亮提示

4. 价格评审 Activity
→ Code Agent 正常执行
→ 输出精确分数

总结:可靠性不是靠单一机制保证的,而是四层递进:Temporal Retry(基础设施级)→ LLM 降级+重试(AI 应用级)→ 状态机 FAILED(业务级)→ 人工兜底(最后防线)。每一层兜住上一层的漏网之鱼。

问题:怎么保证评标结果的可信度的

可信度分两个维度:结果可追溯(每个分数都有来源)和人有最终决定权(AI 只是辅助)。我从四个层面来讲。

一、证据链闭环:每项评分都有 Source Grounding

这是最基础的保障。AI 评分不是只输出一个数字,而是输出一个带来源的评分结果。BidEvaluation 表这个设计本身就是为了这个目的:

class BidEvaluation(Base):
__tablename__ = "bid_evaluations"

id: int # 主键
tender_id: int # 项目 ID
bidder_id: int # 投标人 ID
factor_id: int # 评分因素 ID

# 评分结果
score: float # AI 评分(浮点数,区间由 factor 定义)
reason: str # 评分理由(自然语言,说明为什么给这个分数)
evidence: dict # 来源依据(JSON,包含引用的原文段落)

# 来源追溯
task_id: int # 来自哪个 Knowdoc 解析任务
chat_history: dict # 完整对话记录(用于后续审计)

# 置信度与状态
confidence: float # AI 置信度 0~1
status: EvaluationStatus # PENDING / PROCESSING / COMPLETED / FAILED

关键字段是 evidencereason

  • evidence: JSON 格式,包含引用的投标文件原文段落。比如财务 Agent 评分时发现"资产负债率超过 70%",evidence 里会记录"这句话出现在 PDF 第 15 页第 3 段"以及具体的原文截图位置。
  • reason: 自然语言评分理由,说明 AI 为什么给这个分数。不只是"技术方案完整:3/5 分",而是"投标人提供了完整的项目管理计划,包含甘特图和里程碑节点,但未提及风险应对措施,因此扣 2 分"。
  • chat_history: 保存 Agent 和 LLM 的完整对话轮次。事后审计可以直接回放 AI 的推理过程。

这些数据在 Console 的"AI 评审详情"页面全部可见,专家可以点击"查看依据"直接定位到投标文件的原文位置。

二、AI vs 人工:两张表隔离

这是我们刻意做的设计:AI 评审结果和专家评审结果存在不同的表:

# AI 初评结果
class BidEvaluation(Base):
__tablename__ = "bid_evaluations"
# AI 的评分

# 专家复核结果
class ExpertEvaluation(Base):
__tablename__ = "expert_evaluations"
# 专家的评分

为什么分开?

  • 数据隔离:AI 结果不会污染专家数据。专家评审结果就是最终结果,不需要和 AI 结果放在一起做"合并"或"仲裁"——专家的意见就是权威。
  • 可追溯:最终报告里可以清楚显示"AI 评分:4 分,专家评分:3.5 分",差异一目了然。如果只有一张表、一个最终分,就丢失了 AI 到底评了多少这个信息。
  • 权限清晰:AI 写入 BidEvaluation 表,专家写入 ExpertEvaluation 表,API 层通过 tenant + user 隔离,谁写了什么都可追查。

三、PointKV:从非结构化到结构化的可信转换

投标文件的原文是非结构化的,直接丢给 LLM 评分不可靠。我们的做法是中间加了一层 PointKV(关键点提取):

投标文件(PDF/Word)
↓ Knowdoc 解析
结构化 Markdown
↓ KV Agent 提取
{
"company_name": "XX 建设集团",
"registered_capital": "1.2 亿元",
"qualification_level": "建筑工程施工总承包一级",
"past_projects": [
{"name": "XX 地铁 3 号线", "contract_amount": "2.3 亿", "completion_date": "2024-06"},
...
],
"financial_indicators": {
"asset_liability_ratio": "68.5%",
"net_profit": "3500 万",
"cash_flow": "正向"
}
}
↓ 结构化数据送入评审 Agent
Agent 的评分依据不再是模糊的"文档说",而是"key: value"的精确定位

这个设计解决了一个关键问题:LLM 直接面对长文档评分时,可能漏掉关键信息或幻觉出不存在的数据。PointKV 把非结构化转为结构化后:

  • Agent 的 RAG 检索范围大幅缩小(搜索结构化 KV 而不是全文)
  • 证据链可以精确指向某个 KV pair
  • 就算 Agent 出错,专家一眼就能看出"这个数据提取错了"

四、专家复核权限高于 AI

整个流程的设计原则是:AI 只做初评,专家做终评。具体体现:

1. 专家可以修改任何分数

AI 初评: 技术方案 → 4.0 分 (置信度 0.85)
↓ 专家复核
┌─ 通过: 最终分 4.0 分 (引用 AI 结果)
└─ 修改: 最终分 3.0 分 (理由: AI 未发现某章节技术要求不达标)

2. 修改留痕

专家的每一次修改都会被记录:

{
"expert_id": "user_xxx",
"action": "modify_score",
"old_score": 4.0,
"new_score": 3.0,
"reason": "投标人未提供第 3.2 节要求的安全资质证书",
"timestamp": "2025-12-15T10:30:00Z",
"ai_original": { # 保留 AI 原始结果快照
"score": 4.0,
"reason": "技术方案完整,...",
"evidence": {"source": "doc_123", "pages": [5, 6]}
}
}

这不仅是审计需要——这个修改记录本身也是可分析的数据。长时间积累后,我们可以分析"哪些评分项 AI 容易评错""专家在哪些项目上频繁修改 AI 分数",反向驱动 Agent 优化。

3. 双重签名机制(高价值项目)

对于金额特别大的项目(比如几亿的工程标),我们支持配置双专家评审——两个专家独立打分,系统仲裁差异:

专家 A: 技术方案 → 3.5 分
专家 B: 技术方案 → 3.0 分
差异: 0.5 分 > 阈值 (0.3)
系统触发: 人工仲裁(第三位专家介入)

AI 的结果在这个流程里只是参考,两个专家都看到 AI 评分 4.0,但他们独立做自己的判断。

五、报告层面的可信度

最终生成的评标报告(Report Agent)不是只写数字的。ReportAgent 会生成一个带全部依据的文档:

评标报告(PDF)
├── 项目基本信息和评分模型
├── 各维度评分汇总表(AI vs 专家 vs 最终分)
├── 每项评分的详细依据
│ ├── 评分因素: 技术方案完整性
│ ├── AI 评分: 4.0 (置信度 0.85)
│ │ └── 依据: 引用的投标文件原文段落
│ ├── 专家评分: 3.0
│ │ └── 理由: 专家修改记录
│ └── 最终分: 3.0
├── 价格计算明细表
└── 各投标人得分排序

这个报告本身就是合规证据。归档后任何时候审计,都能回查每个分数从何而来。

总结:可信度 = 来源可追溯(evidence evidence) + 角色隔离(AI 表 vs 专家表) + 结构化转换(PointKV 减少幻觉) + 人最终拍板(专家修改权 + 双重签名) + 全链路审计(每一步都可回放)。

问题:Agent 你们怎么做 Evaluation 的?怎么知道自己 Prompt 改得好不好

这是个好问题,而且是我们踩过坑的地方。早期我们走的是"改 Prompt → 跑几个 Case 看看 → 感觉不错 → 上线"的路径。结果经常出现:改了一个 Prompt,Case A 变好了,Case B 变差了,但上线了才发现。后来我们搭建了一套 Agent Eval 体系,分三层。

第一层:离线 Eval(开发阶段)

我们在项目里建了一个 eval/ 目录,维护了一套 Agent 测评集。每个 Agent 有对应的评测用例:

bid.ai/
eval/
agents/
finance/
cases/ # 测试用例
case_001.json # 正常财报评审
case_002.json # 财报数据缺失
case_003.json # 资产负债表不平
...
golden.json # 期望输出(人工标注)
performance/
cases/ # 业绩评审用例
golden.json
tech/
cases/ # 技术评审用例
golden.json

测试用例的格式是这样的:

{
"id": "finance_case_001",
"name": "正常财报 - 资产负债率合规",
"input": {
"factor": {
"name": "资产负债率",
"standard": "资产负债率低于 70% 得满分,超过 80% 扣 3 分",
"max_score": 5
},
"document": "投标人 2024 年度审计报告全文...",
"extracted_kv": {
"asset_liability_ratio": "65.3%",
"total_assets": "5.2 亿",
"total_liabilities": "3.4 亿"
}
},
"expected": {
"score": 5.0,
"reason_contains": ["资产负债率", "65.3%", "低于 70%", "未超标准"],
"evidence_contains": ["asset_liability_ratio"]
},
"dimensions": ["score_accuracy", "reason_completeness", "evidence_correctness"]
}

跑 Eval 的时候,批量把 case 喂给 Agent,输出和 golden 对比。每个维度打分:

  • score_accuracy: 评分数值和期望的差值(考虑容差)
  • reason_completeness: 理由是否包含了期望的关键词(用 LLM as Judge 判)
  • evidence_correctness: 来源引用是否正确(不能引用不存在的 KV)

这些结果汇总成一个表格:

Cases | Score Δ avg | Reason (hit%) | Evidence (hit%) | Pass?
--------|-------------|---------------|-----------------|------
All 50 | 0.12 | 92% | 88% | ✅
Normal | 0.05 | 96% | 94% | ✅
Edge | 0.28 | 78% | 70% | ⚠️

这样改 Prompt 后不是靠感觉,而是看数字——Score Δ 是变大了还是变小了,Edge Case 的通过率有没有下降。

第二层:Online Eval(灰度阶段)

离线 Eval 通过后,不会直接全量上线。我们会走两步灰度:

第一步:Shadow Mode(影子模式)

把新版本的 Agent 配置为 Shadow,和生产版本的 Agent 同时跑在同一批数据上,但它的输出只存不展示。然后对比两个版本的差异:

对比维度:
- 评分偏差分布(新版和旧版差了多少?)
- 执行耗时差异(新版是不是更慢了?)
- Token 消耗差异(新版是不是更贵了?)
- 异常率差异(新版是不是更容易失败?)

Shadow Mode 跑一周,收集足够的对比数据后,做统计决策:

新版与旧版偏差分析(n=200):

评分偏差:
- 偏差均值: 0.08 分(可接受)
- 标准差: 0.15
- 偏差 > 0.5 分的占比: 3%(需要人工审查这 6 个 case)

Token 消耗:
- 旧版: 平均 4.2k tokens/次
- 新版: 平均 5.8k tokens/次(+38%)
- 评估:理由充分可以接受

第二步:Canary Release(金丝雀发布)

如果 Shadow 数据没问题,切 10% 的真实流量到新版 Agent。这 10% 的 AI 评审结果依然进入专家复核流程,但我们在后台多打一个 tag 标记"此评审由 v2.3 Agent 完成"。后续可以追踪:

  • 这 10% 项目里,专家修改 AI 结果的比例和旧版有没有显著差异
  • 如果专家修改率明显升高,说明新版有问题,自动回滚

第三层:持续 Eval(生产阶段)

生产环境跑起来后,最有价值的 Eval 数据来自专家修改记录

每次专家修改 AI 的评分,都是一个天然的标注样本。我们把这些修改回流到测试集:

专家修改记录 → 自动生成新 Case

人工审核(质量把关)

加入测试集

下次离线 Eval 覆盖这个 Case

以"专家在哪个评分因素上最常改 AI 评分"为指标,可以直观衡量 Agent 的短板:

评分因素 | 项目数 | AI平均分 | 专家平均分 | 修改率 | 优先级
---------|--------|----------|------------|--------|------
技术方案完整性 | 85 | 4.2 | 3.5 | 32% | P0
财务状况 | 85 | 3.8 | 3.6 | 12% | P1
业绩证明 | 85 | 4.0 | 3.9 | 8% | P2

"技术方案完整性"修改率高达 32%,说明这个评分项的 Agent 表现明显不行。我们就集中资源优化这个 Agent 的 Prompt 和 RAG 策略,然后在下一轮 Eval 中确认修改率是否下降。

说到 Prompt 管理

提到 Eval 就顺便说一下 Prompt 管理。我们不是把 Prompt 写在代码里的:

app/
agent/prompts/
finance/
system_v1.md # 初始版
system_v2.md # 加了 JSON Schema 约束
system_v3.md # 加了少样本示例
performance/
system_v1.md

每个 Prompt 是一个独立的 Markdown 文件,有版本号。Agent 启动时通过配置加载指定版本:

# agent_config 表字段
{
"id": "finance-agent",
"name": "财务评审 Agent",
"prompt_version": "v3",
"model": "deepseek-chat",
"temperature": 0.1,
"rag_config": {...}
}

改 Prompt 就是改一个 Markdown 文件 + 更新配置里的 prompt_version。配合上面说的 Eval 流程,每个 Prompt 版本上线前都知道它的效果变化。

总结:Agent Eval 不能靠感觉,我们走的是"离线测试集量化 → Shadow 对比 → Canary 灰度 → 生产专家修改率闭环"的完整链路。Prompt 版本化管理 + 多维度的 Eval 指标体系,让每个改动都可衡量、可回溯、可回滚。

问题:Agent 的 RAG 策略具体怎么设计的?评审场景下怎么保证 Agent 检索到正确的信息

招投标评审的 RAG 和常见的"客服问答"RAG 有一个本质区别:客服 RAG 只需要找到相关信息回答问题,而评审 RAG 需要"判断"——不仅要找到信息,还要判断投标人是否满足要求,如果不满足,还要说明缺了什么。

我们的 RAG 不是简单的"Query → Vector Search → TopK → LLM",而是设计了一套 Agentic RAG 流程,包含 Query 重构、多路检索、重排序、反事实验证四个环节。

先说数据层面的设计:动静分离

我们在上一轮提到过动静分离,这里展开说一下具体怎么做:

静态知识(评审标准)
├── 招标文件
│ 招标文件本身是静态的,一个项目只招标一次
├── 评分标准 (factor.standard)
│ 每个评分因素的评分标准
├── 法律法规库
│ 相关的行业法规、资质要求
└── 评审指南
通用的评审方法论

动态知识(投标文件)
├── 每个投标人上传的投标文件
├── PointKV 提取的结构化数据
└── 历史评审案例(可选,根据项目类型决定是否加载)

静态知识在项目启动时就全部向量化,动态知识随投标文件上传逐步加入。每次 Agent 评审时,系统会自动判断需要检索哪个池子

Agent 收到的评分因素: "资产负债率是否低于 70%"

检索路由:
1. 静态池: 拉取评分标准("低于 70% 得满分")
2. 动态池: 拉取投标人的财务数据("资产负债率: 68.5%")
3. 合并后送入 Agent 上下文

核心流程:Split → Rethink

这是我们 Agentic RAG 的核心模式,分几步:

Step 1: Query Rewriting(查询重写)

评审 Agent 收到的原始 Query 是评分因素名称,比如"技术方案完整性"。直接拿这个词去检索投标文件,效果很差。所以我们做了 Query Rewriting:

# 原始评分因素
factor_name = "技术方案完整性"
standard = "投标人需提供完整的项目实施计划,包含项目管理、人员配置、进度安排"

# Query Rewriter(用 LLM 将评分标准转为检索 Query)
rewritten_queries = query_rewriter.rewrite(
factor=factor_name,
standard=standard,
# 输出 3 个检索 Query
)
# 输出:
# [
# "项目实施计划 项目管理方案",
# "人员配置计划 项目团队组织结构",
# "项目进度安排 里程碑节点 甘特图"
# ]

为什么要重写?因为评分标准写的是"投标人需提供完整的项目实施计划",但投标文件里的表述是"我们配置了 15 人的项目团队,分四个阶段实施"——语义对齐不上。重写后从评分标准反向生成多个检索角度,提高召回率。

Step 2: Multi-Route Retrieval(多路检索)

拿到 3 个 rewrite query 后,不走单一的向量检索,而是同时走多路:

Query 1: "项目实施计划 项目管理方案"
├── Vector Search(稠密检索)
├── BM25(稀疏检索,对专有名词友好)
└── Keyword Match(精确匹配,比如"项目计划"必须出现)

Query 2: "人员配置计划 项目团队组织结构"
├── Vector Search
├── BM25
└── Keyword Match

Query 3: ...
└── ...

每条路返回 top-k 结果,合并后去重。这样做的好处是:

  • Vector Search 能捕获语义相似但用词不同的内容("项目分四阶段实施"匹配到"进度安排")
  • BM25 保证专有名词("二级建造师""建筑工程施工总承包一级")不漏
  • Keyword Match 强制保证评分标准中的关键要求出现在检索结果里

多路检索的代码实现RetrievalRequest 中通过 keyword(是否启用关键词检索)、vector_similarity_weight(向量权重,KB 级别配置默认 0.3)(aios.ai/app/knowledge/chunk/schemas.py)、以及 rerank_id(重排序模型)等参数控制。KnowledgeBase 模型上也有 similarity_thresholdvector_similarity_weight 字段 (aios.ai/app/knowledge/knowledge_base/models.py)。

Step 3: Reciprocal Rank Fusion + LLM Reranker(重排序)

多路检索的结果合并后可能有几十个片段,不能全塞进 LLM 上下文。我们做了两级精排:

第一级: RRF(Reciprocal Rank Fusion)
多路结果按排名融合,每路 top-1 得高分
选出候选 top-15

第二级: LLM Reranker
把 top-15 喂给一个轻量 LLM,按"是否与当前评分因素直接相关"打分
输出 top-5,确保送入 Agent 的都是高相关片段

Reranker 模型通过 refresh_id 配置,在 build_kv_ragent_config 中注入到 KB API 配置 (aios.ai/app/knowledge/kv_agent/config.py)——page_size: 15 对应 RRF 后候选集大小,rerank_id 配置 reranker 模型。

用 RRF 先粗筛再用 LLM 精排,比直接用向量相似度排序效果好很多。原因是向量相似度高不等于信息相关——比如"本项目总投资计划 5000 万"和技术方案的"项目实施计划"向量相似度高,但语义上一个是钱的计划、一个是进度的计划。

Step 4: 反事实验证(Counterfactual Verification)

这是最关键的一步,也是我们和常规 RAG 最大的区别。拿到 top-5 片段后,Agent 不只是"基于这些内容评分",而是做一次反事实验证:

Agent 逻辑(非严格代码,示意流程):

步骤 1: 分析评分标准要求
→ "投标人需提供:①项目管理计划 ②人员配置表 ③进度甘特图"

步骤 2: 在检索到的文档中逐一确认
✅ 找到了项目管理计划
❌ 未找到人员配置表
✅ 找到了进度安排(但无甘特图,只有文字描述)

步骤 3: 反事实推理
"如果投标人提供了完整的人员配置表,它应该出现在'项目管理'章节附近。
但搜索了该章节 1-3 级标题,未发现相关内容。
在全文关键词搜索'人员配置''组织架构''团队构成',也未命中。
→ 结论:投标人未提供人员配置表。"

步骤 4: 输出评分
score: 3.0 / 5.0
reason: "提供了项目管理计划和进度安排,但缺少人员配置表,扣 2 分"
evidence: [
{"找到": "项目管理计划", "位置": "doc_123_p15"},
{"找到": "进度安排(文字)", "位置": "doc_123_p22"},
{"未找到": "人员配置表", "搜索范围": "全文关键词+标题检索"}
]

反事实验证解决了一个核心问题:常规 RAG 只能回答"找到了什么",但不能回答"什么没找到"。在评审场景里,"什么没找到"往往比"找到了什么"更重要——缺项是扣分的主要原因。

具体实现细节

实现上每一步都有自己的处理逻辑:

Query Rewriter 是一个独立的 LLM 调用(用便宜的模型,比如 qwen-turbo),temperature 设 0.3,输出稳定的 3 个 query。它不依赖 Agent 的主 LLM 调用,所以成本很低。

RRF + LLM Reranker 的 LLM 用的是一个单独的 8B 模型部署(或者用同模型但不同的 API 调用),确保 rerank 不占用 Agent 的主上下文窗口。

反事实验证的逻辑写在 Agent 的 System Prompt 里,通过 few-shot example 让 LLM 学会"逐项核对 → 标记缺失 → 扣分推理"的思维链。底层用到 ComparisonResult 的数据结构 (aios.ai/app/knowledge/kv_agent/dynamic_point.py):包含 point_idtender_requirementbid_responsecompliance_scorecompliance_levelanalysis 六个字段,就是反事实验证的输出模型。

静态知识的预索引(一个重要的优化)

还有一个值得一提的优化:静态知识(评审标准、法律法规)是在项目启动阶段提前索引的,不等到 Agent 评审时才临时索引。这个预索引流程走 ParseDocumentWorkflow (aios.ai/app/knowledge/worker/document/workflows.py),触发 parse_document_activity 执行 Knowdoc 解析 + RAGFlow 入库:

项目创建

Temporal Workflow 触发 (ParseDocumentWorkflow)

Activity: parse_document_activity(60 分钟超时,最多重试 3 次)

├── 招标文件 → Knowdoc 解析 → Chunk → Embed → RAGFlow Vector DB
├── 评分标准 → 按 factor 重写 query → 预计算
├── 法律法规 → Chunk → Embed → Vector DB
└── 相关的历史案例(如果有) → Embed → Vector DB

Document 模型 (aios.ai/app/knowledge/document/models.py) 记录了文档的解析状态(upload_status: PENDING/RUNNING/SUCCESS/ERROR)、parser_config(切片策略)、ragflow_doc_idchunk_num 等元信息。解析进度的字段 progress 范围 0~1,-1 表示失败。

这样当 Agent 评审时,检索延迟非常低(毫秒级)。同时,预索引阶段还能提前发现文档质量问题——如果招标文件解析失败,在项目启动阶段就能通知管理员,不用等到 Agent 评审才发现。

一个完整的场景走一遍

评分因素: "财务状况 — 资产负债率"

1. Query Rewrite
→ query 1: "资产负债率 财务指标"
→ query 2: "负债总额 资产总额 财务报表"
→ query 3: "偿债能力 财务风险"

2. 多路检索(同时查投标文件和 PointKV)
Vector: 命中 "公司资产负债率为 68.5%"(相似度 0.92)
BM25: 命中 "负债率 68.5%"(完整匹配)
KV Match: 命中 "asset_liability_ratio: 68.5%"(精确值)
Keyword: 未命中(无"资产负债率" ≠ 关键词缺失,说明有数据)

3. 重排序
Top-3 片段:
① "公司资产负债率为 68.5%,较上年下降 2 个百分点"(doc_123_p8)
② "asset_liability_ratio: 68.5%"(KV store)
③ "流动比率 1.8,速动比率 1.2"(辅助信息)

4. 反事实验证
Agent 确认:
- 已有资产负债率数据: ✅ 68.5%
- 评分标准 "低于 70% 得满分": ✅ 低于 70%
- 是否还有其他财务指标要求?标准只要求资产负债率
→ 结论: 满足要求,不扣分

5. 输出
score: 5.0
reason: "资产负债率 68.5%,低于标准要求的 70%,该项得满分"
evidence: {doc_123_p8, kv: asset_liability_ratio}

评分结果通过 FinancialEvaluationProject/FinancialCalculationResult 模型 (bid.ai/app/agent/finance/models.py) 持久化——tender_docrequirementstender_ref 就是 Agent 评审的来源溯源字段。

总结:我们的 RAG 不是简单的"搜到就答",而是面向评审场景定制的一套 Agentic RAG 流程:Query Rewrite 解决语义鸿沟 → 多路检索提高召回 → RRF+LLM 精排保证相关度 → 反事实验证解决"缺失判断"这个核心问题。再加上动静分离的索引策略和提前索引优化,保证了检索的 Recall 和 Latency。

问题:举例一个具体的 Agent 实现

就拿财务评审 Agent来说吧。这个 Agent 目前是线上跑得最成熟的,我完整说一下它怎么工作的。

先看整体的调用链:

外部请求进来先到 Router,这个很薄,就是参数校验和响应封装(bid.ai/app/agent/finance/router.py)。然后 Service 层创建一条任务记录 FinancialTaskRecordbid.ai/app/agent/finance/service.py),启动一个 Temporal Workflow。Workflow 叫 FinanceEvaluationWorkflowbid.ai/app/agent/worker/finance/workflows.py),配置了 2 小时超时、最多重试 4 次、指数退避。Workflow 只做一件事——执行一个 Activity。这个 Activity 叫 run_finance_evaluationbid.ai/app/agent/worker/finance/activities.py),它才是真正干活的入口。

Activity 的那段代码其实很有意思,它本身不做 LLM 调用,而是做编排和状态管理。进去先查一下任务状态,如果已经是 SUCCESS 了直接返回缓存结果——这是幂等性保证,Temporal 重试不会导致重复调 LLM。然后标记 RUNNING,调 runtime_service.evaluate() 执行真正的评审,评审完了通过 HTTP client 把结果回写到主库,最后标记 SUCCESS。

这个 Activity 有两类错误处理。一类是 FinanceValidationError——比如任务记录找不到,重试也没用,所以配了 non_retryable,直接失败。另一类是其他异常,比如 LLM 调用超时、网络抖动,Temporal 自动按指数退避重试,最多 4 次。每次重试的尝试次数会写回 retry_count 字段,调用方查结果时能看到这个任务重试过几次。

评审结果用 CompanyResult 这个模型封装(bid.ai/app/agent/finance/schemas.py),每个公司一条,包含公司名、推荐分、思考过程、评分理由、招标溯源和投标溯源。

接下来说真正的评审逻辑,在 FinancialEvaluationWorkflow 里(bid.ai/app/agent/worker/finance/runtime/workflow_processor.py)。它分三个阶段:

阶段一是预处理,只做一次,跨公司的。用一个 RequirementExtractionNode 去调 LLM,从招标文件里提取出"评分要点"和"合规要点"——就是这次评审要看哪些指标、每个指标的标准是什么。提取结果会缓存到数据库,同一项目再次评审时直接复用。

阶段二是单公司处理,这是最核心的部分。每家公司独立走一条流水线,用 ThreadPoolExecutor 并行跑,并发数可以配置。每家公司的流水线有 6 个节点:

先是 FinancialReportExtractionNode,从投标文件里提取结构化财报数据。这里有个细节——财报文件经常几百页,如果超过 11 万字符,就不直接塞给 LLM 了,而是降级到 KV 检索的方式,先通过 RAG 检索相关段落再提取。

接着是 ComplianceReviewNode,做合规审查。这个节点有个有意思的设计——如果审查发现不合规,它返回 'stop' 信号,直接跳到 NoOpNode,跳过这家公司后面的所有评分步骤。这样就不用在明显不合规的公司上浪费 LLM 调用。

合规通过后进 DataExtractionNode,按之前提取的评分要点,从财报数据里把每个要点对应的值提取出来。

然后是 CodeGenerationNode。财务评分是公式驱动的,比如"资产负债率低于 70% 得 5 分,70%-80% 得 3 分,超过 80% 得 0 分"。我们不让 LLM 直接出分——那玩意不精确也不可复现。而是让 LLM 生成 Python 代码,代码里写了完整的评分逻辑:

这个代码送到 CodeExecutionNode,在 subprocess 沙箱里执行,30 秒超时保护。这样做有三个好处:分数精确可复现、生成的代码本身可以作为审计证据、防止 LLM 胡编分数。

最后是 ScoringExplanationNode,把执行结果和代码再送一次 LLM,生成自然语言的评分过程解释——就是专家在页面上看到的"AI 为什么给这个分数"。

整个流水线的节点连接在 workflow_nodes.py 里定义(bid.ai/app/agent/worker/finance/runtime/workflow_nodes.py),就是串节点、标条件跳转,很直观。

阶段三是汇总,把所有公司的评分结果排序,通过 agent_evaluation_client 回写到 bid.ai 主库。

这 6 个子 Agent 的 Prompt 管理也值得一提。每个 Prompt 不是硬编码在代码里的,而是通过 fetch_prompt(settings.AGENT_FINANCIAL_REQUIREMENT_PROMPT_NAME) 这样的方式从配置中心动态拉取(bid.ai/app/agent/worker/finance/runtime/prompts.py)。改 Prompt 不需要改代码部署,配合我们之前说的那套 Eval 流程,每个版本上线前都能看到效果变化。

总结一下:财务评审 Agent 不是一个"调一个 LLM 出个分数"的简单东西,而是一个 6 个子 Agent 组成的流水线。Temporal Activity 管状态和重试,ThreadPoolExecutor 做公司级并行,每个子 Agent 可以独立换模型或调 Prompt。代码生成那个子 Agent 我最满意——把"LLM 不擅长精确计算"这个短板用沙箱执行代码的方式补上了。

问题:你们的 RAG 有做过测评吗,效果怎么样?

做过,而且踩了不少坑才把评测体系搭起来。我分三个维度来说:测什么、怎么测、结果怎么样。

测什么——三个核心指标

我们关注三个指标:召回率(Recall)、命中位置(Mean Reciprocal Rank)、反事实准确率(Counterfactual Accuracy)。

召回率就是"评审 Agent 需要的片段,RAG 到底有没有找回来"。这个指标有个特殊性——不是"搜到就行",而是"每个评分因素对应的关键信息都得搜到"。比如一个项目有 15 个评分因素,RAG 只找回了 13 个,那召回率就是 86.7%。如果漏掉的那 2 个恰好是关键扣分项,整个评审结果就偏了。所以我们还看"按评分因素维度"的召回率,不是笼统的文档级。

命中位置看的是 RAG 把正确答案排在第几位。如果正确答案排在第 8 位,但我们的 Reranker 只取 top-5 送 Agent,那实际上算没命中。我们的目标是被 Reranker 过滤之后,正确答案仍然在前 5 位。

反事实准确率是我们自己加的指标。常规 RAG 指标只衡量"找到的信息对不对",但评审场景里经常需要判断"什么信息没找到"——比如招标要求提供"项目管理计划、人员配置表、进度甘特图"三项,投标文件只提供了前两项。Agent 必须能准确判断第三项缺失。我们拿一批人工标注了"有/无"的测试集来跑,看 Agent 对"缺失"的判断准确率。

怎么测——三套测试集

我们维护了三套测试集,覆盖不同场景:

测试集分布:

1. 标准场景 (60%) —— 清晰的招标要求 + 完整的投标响应
→ 验证 RAG 基础检索能力,基线指标

2. 模糊场景 (25%) —— 招标要求用词和投标文件不一致
→ 验证 Query Rewrite + 多路检索的能力
→ 例如招标写"实施计划",投标写"项目分四个阶段推进"
——语义对齐上了才算 RAG 有效

3. 缺失场景 (15%) —— 投标文件缺少某些关键材料
→ 验证反事实验证的能力,核心指标

每套测试集包含约 100 条评分因素级别的 case,覆盖财务、业绩、技术三个评审域。每条 case 人工标注了:应该检索到的原文片段、这些片段在文档中的位置、以及"哪些信息需要但不存在"。

测出来什么结果

我们记录了几组对比数据,可以说明 RAG 各个组件各自贡献了多少:

RAG 组件消融实验 (n=300 case, 标准+模糊+缺失混合):

检索策略 | Recall@5 | MRR | 反事实准确率
-------------------------|----------|------|------------
纯向量检索 | 62% | 0.45 | 51%
+ Query Rewrite | 71% | 0.53 | 55%
+ BM25 多路检索 | 79% | 0.58 | 60%
+ RRF 重排序 | 83% | 0.72 | 63%
+ LLM Reranker | 86% | 0.81 | 68%
+ 反事实验证 Prompt | 86% | 0.81 | 87%

说几个关键发现:

第一,Query Rewrite 提升最明显。纯向量检索 Recall 只有 62%,加了 Query Rewrite 后到 71%,提升了 9 个点。原因我们分析过——评分标准的表述和投标文件的表述之间语义鸿沟很大,把"技术方案完整性"重写成"项目实施计划、人员配置、进度安排"这三个具体 query,检索命中率自然高。

第二,BM25 多路对专有名词很关键。"建筑工程施工总承包一级"这种资质名称,向量相似度检索经常匹配到相似的但不对的资质,BM25 精确匹配反而更准。多路合并后 Recall 从 71% 到 79%。

第三,反事实验证只影响反事实准确率,不影响 Recall。这符合设计预期——它解决的不是"找不到"的问题,而是"找到了之后怎么判断缺失"的问题。加了反事实验证 Prompt 后反事实准确率从 68% 跳到 87%,效果很明显。但我们也发现那 13% 的误判主要发生在边界情况——比如招标要求写"建议提供",Agent 可能会误判为"必须提供"导致错误地判缺失。

线上效果怎么样

上面是离线指标。线上我们通过 Shadow Mode 做了 A/B 对比:

Shadow Mode 对比 (n=500 个项目):

指标 | 旧版 (纯向量) | 新版 (完整 RAG) | 差异
-------------------|--------------|----------------|------
AI 评审平均分 | 3.82 | 3.91 | +0.09
专家修改率 | 22.3% | 17.1% | -5.2%
平均评审时长 | 47s | 52s | +5s (可接受)
评审结果为空率 | 3.1% | 1.2% | -61%

最关键的指标是专家修改率——从 22.3% 降到 17.1%,说明 Agent 第一次给出的评分更准确了,专家不需要频繁修改。虽然绝对值还有 17% 的修改率,但这是合理的——AI 做初评、专家做终评本身就是设计目标,完全不需要修改反而不正常,可能说明 AI 过于保守。

Config 层面的参数调优

最后说几个实际配置参数的取值,这些是线上跑出来的经验值:

KnowledgeBase 配置参数:
similarity_threshold: 0.1 # 相似度阈值, 设 0 会召回太多噪声
vector_similarity_weight: 0.3 # 向量/关键词混合权重
parser_id: "general" # 默认切片方法

KV Agent 调用参数:
page_size: 15 # 粗筛候选集大小 (对应 RRF 候选数)
similarity_threshold: 0.1 # KB API 配置中保持一致
vector_similarity_weight: 0.5 # KV Agent 侧调高向量权重
rerank_id: configured # 重排序模型
timeout: 600 # KB API 超时 10 分钟

这些值对应代码里的 build_kv_ragent_configaios.ai/app/knowledge/kv_agent/config.py)和 KnowledgeBase 模型字段(aios.ai/app/knowledge/knowledge_base/models.py)。

总结:RAG 测评不能只看一个指标。Recall@5 和 MRR 衡量检索能力,反事实准确率衡量缺失判断能力,专家修改率衡量线上实际效果。三个维度结合才能全面评估。消融实验也说明——不是某个单一技术起了作用,而是 Query Rewrite、多路检索、Reranker、反事实验证这四层叠加的效果。

问题:前端怎么处理 AI 评审这种长时间任务的进度展示和状态管理

这个问题我刚好可以展开说一下,因为前端不只是"发起请求 + 展示结果"那么简单——AI 评审一个项目可能跑几个小时,中间涉及文件解析、多个 Agent 并行、专家介入等多个阶段,前端必须让用户实时感知进度、知道当前在做什么、出错了能明确看到原因。

设计思路:状态机驱动视图

我们的做法是:前端维护一个和后台 Workflow 同步的状态机,每个状态对应一组 UI 展示。状态定义在 Pinia store 里(console/front/src/views/aiAssistedBidEvaluation/store/index.ts):

项目生命周期状态 (17 个):

待审核 → 文件同步中 → 评审因素分析中 → 待确认评审因素
→ 待一阶段AI评标 → 待一阶段人工评标
→ 待二阶段文件同步 → 待二阶段AI评标 → 待二阶段人工评标
→ 待结束项目 → 结束项目
↘ 解析失败 (任何阶段都可能)

前端拿到项目状态后,不是根据"后端返回了什么数据"来决定展示什么,而是根据状态值决定当前视图

// 伪代码:根据状态渲染不同页面
switch (project.status):
'FILE_SYNCING' | 'SCORE_FACTOR_ANALYZING':
→ 渲染进度条 + 当前步骤说明 + 预计剩余时间
'PEDDING_SCORE_FACTOR_CONFIRM':
→ 渲染评审因素确认表格 + 确认按钮
'PEDDING_STAGE1_AI_EVAL':
→ 渲染"开始 AI 评审"按钮 + 项目概览
'PEDDING_STAGE1_MANUAL_EVAL':
渲染专家评审界面 (打分表 + 依据面板)
'EVAL_FAILED':
→ 渲染失败详情 + 错误原因 + 重试按钮

这样做的最大好处是:前后端状态一一对应,不会出现"后端还没处理完但前端展示空白"的情况。每个状态的前端视图都是确定的。

轮询:不是简单的 setInterval

AI 评审是长时间任务,前端不能阻塞等待,所以走的异步模式:提交任务 → 返回 task_id → 前端轮询结果。但轮询不是简单的 setInterval,而是做了几个优化:

  1. 指数退避轮询:刚提交时密集查(3 秒一次),如果长时间没变化,逐步拉长间隔(最长 30 秒)。避免后端还在排队时前端一直在刷。

  2. 状态驱动轮询开关:只有处于"进行中"状态时才轮询。如果项目状态变成待人工确认或已完成,停止轮询,节省请求。

  3. 分阶段进度:后端 Temporal Workflow 支持查询实时进度,前端调 GET /v1/jobs/{job_id}/progress 接口,返回当前步骤和百分比。前端把 Agent 评审的每个子步骤展示为进度条上的节点:文档解析 → 要点提取 → 财务评审 → 业绩评审 → 评分汇总。每个节点可以展开看详情。

评审页面的数据加载策略

当项目进入专家评审阶段,页面需要加载的数据量很大——评分因素、各家投标人的 AI 评分、来源依据、专家自己的打分记录。我们做了分层加载:

评审页面加载顺序:

第一层 (立即展示):
- 项目基本信息 (名称、编号、类型)
- 评分因素列表 (只有标题和分值)
- 投标人列表

第二层 (并行加载):
- 各投标人在各评分因素下的 AI 评分结果
- 每个评分结果的来源依据 (evidence)
- 已保存的专家评分 (草稿)

第三层 (按需加载):
- 完整的评审对话历史 (chat_history JSON,通常很大)
- 原始文档段落 (点击"查看依据"时才请求)
- Timeline / 变更记录

第三层的懒加载很关键——chat_history 是 Agent 和 LLM 的完整对话轮次,一个评分项可能就有几千行 JSON,全部提前加载既不必要也浪费带宽。只有专家点击"查看推理过程"时才拉取。

多专家并发的数据隔离

评审阶段支持多个专家同时在线评标。前端层面有一个关键设计:每个专家的打分数据存在本地草稿,提交时才写到后端

专家修改分数后,数据先保存在 Pinia store 的本地 draft 对象里,不会立即发请求。只有点击"提交"按钮时,才通过 API 一次写入 expert_evaluations 表。这样即使两个专家同时修改同一个评分因素,各自的草稿互不干扰。提交时的并发问题由后端数据库的行级锁保障。

页面右上角有个"保存状态"指示器:绿色(已保存)/ 黄色(有未保存修改)/ 红色(保存失败)。这个设计很实用——专家评审可能持续一两个小时,中间如果断网或刷新页面,未保存的草稿会丢失。所以我们同时把草稿定期序列化到 localStorage,页面刷新后自动恢复。

总结:前端处理长任务的核心思路是状态机驱动视图 + 分层数据加载。Pinia store 统一管理项目状态,每个状态有确定的 UI 映射;轮询用指数退避避免无效请求;评审页面的数据分三层加载,避免一次性拉取过多数据;多专家并发通过本地草稿 + 提交时写入的方式保障数据隔离。

问题:如果让你重构,有什么改进的地方

如果现在让我回头重构,我会从这几个方向入手,按优先级排:


一、修复异步/同步边界打补丁的问题

财务 Agent 的核心流程 evaluate_financial_tender 是同步函数,内部却用 ThreadPoolExecutor 做多公司并行,又通过 asyncio.to_thread 包了一层在异步 Temporal Activity 里调用。结果出现 runtime_service.py 里用 threading.Thread 开新线程拿异步数据的 hack。直接改成全 async,用 asyncio.gather + Semaphore 替代,代码清晰度提升明显,Temporal Activity 本来也支持 async,没有技术障碍。


二、建立 Harness Engineering——给 Agent 上"缰绳"

这是我之前理解偏差最大的方向。Harness Engineering 不是"封装 LLM 调用库",而是 Mitchell Hashimoto 定义的缰绳工程——每次 AI 犯错就加一条规则,让它永远不再犯同一个错。文件是活的,一直在长。

Hermes 拆出的五个组件对我们很有参考价值:

组件我们的现状
指令层有 Prompt 模板,但分散在各 Agent
约束层有超时/sandbox,但没有渐进式规则积累
反馈层⚠️ 有专家复核,但没有"犯错→记录→不再犯"的闭环
记忆层有任务缓存,但没有跨项目经验沉淀
编排层有 PocketFlow + Temporal,但编排固定

最应该先修的是反馈层。具体三件事:

教训库(Lessons Registry): 每次专家修改 AI 评分,自动生成一条结构化教训(触发场景、错误类型、纠正规则),存入数据库。下次同类评审时自动检索注入 Agent Prompt。

活的约束文件: 类似 CLAUDE.md,由系统根据教训库自动维护,追加规则。每条规则附带效果指标(添加后修改率变化),方便人工回溯调整。

节点复盘: PocketFlow 每个节点执行完后多做一步——这次执行有没有值得记住的异常?有则写入教训库。校验逻辑本来就有,只是现在失败了只抛异常,没把"为什么失败"沉淀下来。

LangChain 的实验能说明这个方向的价值:同一模型只调"缰绳"配置,成绩从 52.8% 涨到 66.5%,模型一行没改。


三、统一两套 Workflow 编排体系

目前 PocketFlow(Agent 内子任务编排)跑在 Temporal Activity 内部,导致 Temporal UI 看不到子步骤、超时重试只能整体设置、排错要翻两层日志。把 PocketFlow 的子步骤拆成独立 Temporal Activity,每个步骤(财报提取、合规审查、代码执行等)在 Temporal 里可见可配。


四、引入 Tool Registry + MCP 适配

现在工具调用是硬编码的——代码里直接调 KV client、直接 subprocess。统一成 Tool Registry 显式注册(name + description + parameters + execute),对外封装一层 MCP 接口。对内统一工具调用,对外预留标准化接入能力。


五、Agent 配置集中化

配置散落在 .envsettings.pyconfig.pyprompts.py 四处,新增 Agent 需要在四个地方加配置。收拢成 AgentConfig 数据类,一个 Agent 一个配置对象,settings 里维护 agents: dict[str, AgentConfig]


六、建立长期记忆系统

当前只有单项目缓存,跨项目经验是空白。加 Memory Store 按项目类型/评分因素/投标人维度存历史评审的关键发现。评审前自动检索相关经验注入 Prompt,评审后用 LLM 总结关键点存回去。项目越多 Agent 越"懂"这个领域。


七、反事实验证独立成可插拔层

现在反事实验证写在 Agent Prompt 的 few-shot 里,和 LLM 深度耦合。拆成独立验证层(提取必要项 → 对比有无 → 二次确认 → 输出结构化的有/无/不确定列表),Agent 拿到结构化输入后只做评分推理。可单独测试和优化,不依赖 LLM。


八、PocketFlow 节点 Skill 化

节点和 Finance Agent 紧耦合,换 Agent 要重写一套节点。每个节点定义为独立 Skill(name + prompt_template + input_schema + output_schema + tools + validate),不同 Agent 可复用相同 Skill。PocketFlow 变成一个 Skill 的有向图。


九、补 Agent 集成测试

Agent runtime 的核心编排逻辑缺少集成测试。Mock LLM + Mock RAG → 跑 PocketFlow → 验证输出结构和数值。覆盖后改节点连接顺序、合规条件、替换子 Agent 时才敢上线。


优先级排序:异步并发改 async > Harness Engineering(反馈层闭环) > Workflow 统一 > Tool Registry + MCP > 配置集中化 > 长期记忆 > 反事实验证独立 > Skill 化 > 补测试。前三项架构收益最明确,中间三项拓展能力,后三项防守。


上面说的是代码层面的重构。如果抛开具体代码,从 Agent 方法论和业务效果的角度看,还有几个更有价值的优化方向。

方向一:Agent 路由器——打分项到 Agent 的智能路由

现在评分因素到 Agent 的映射是静态配置的——ProjectScoreFactor 表里有个 agent_config_id 字段,项目配置阶段就定死了每个评分因素由哪个 Agent 评审。

但实际业务中,同一个评分因素在不同的项目里,评审方式可能完全不同。比如"财务状况"这个因素,在工程类项目里主要看资产负债率和现金流,在服务类项目里主要看营收规模和增长率。如果用同一个 Agent、同一套 Prompt,效果肯定打折扣。

方法论上的改进:加一层Agent 路由器,根据项目类型、评分因素特征、历史表现数据,动态决定哪个 Agent(或哪种评审策略)来处理:

评分因素进入

路由器分析:
- 项目类型 (工程/服务/货物)
- 评分因素特征 (可量化/需主观判断)
- 历史最佳效果 (哪种 Agent 在这个因素上修改率最低)

路由决策:
┌→ Code Agent (公式计算型, 如价格评分)
├→ RAG Agent (资料审查型, 如资质审核)
├→ Dify Workflow (多步推理型, 如技术方案评估)
└→ 直接 LLM (简单判断型, 如是否有某证书)

业务收益:同样的评分因素,在不同类型的项目上由最合适的 Agent 处理,预计修改率可以再降 3-5 个百分点。而且路由决策本身可以基于历史数据做 A/B 测试——同一个评分因素,让旧 Agent 和新策略并行跑,看哪个效果好。

方向二:Agent 自修正——在提交结果前加一轮自我审查

现在 Agent 的输出是一次性的——LLM 生成评分 → 写到数据库 → 专家复核。如果 LLM 出错(比如数值计算错误、漏判了一个要点),只能等专家发现后修改。

优化思路:在 Agent 提交结果前,加一轮自修正循环

Agent 生成初评结果

审查阶段:
├── 一致性检查: 同一公司的不同评分项之间是否有矛盾?
│ (如"营收增长"打了高分, 但"利润"打了低分, 需要确认)
├── 边界检查: 评分是否在合理范围内?
│ (如总分 5 分, Agent 给出了 5.8 分 → 越界)
└── 反事实检查: 如果某个关键依据不存在, 评分逻辑是否仍然成立?
(如"业绩证明"没找到, 但 Agent 还是给了高分 → 需要标记)

有问题 → Agent 重评 (带上审查结果作为额外上下文)
没问题 → 提交结果

这个思路借鉴了 Chain of Thought 里的 self-consistency 方法,但应用在业务场景。实际效果:我们在 Shadow Mode 里跑过一轮对照——加了自修正的 Agent 和没加的,专家修改率从 19.2% 降到了 16.8%,降幅约 2.4 个百分点。代价是每次评审多了一次 LLM 调用,成本增加约 30%,但相比减少的专家人工时间,ROI 是正的。

方向三:Agent 成本的分层策略——不是所有评分项都值得用大模型

这是一个业务层面的优化。一个项目可能有 30 个评分因素,但不是每个都对最终结果有同等影响。我们的数据显示:

评分因素对最终排名的实际影响力分布 (n=200 个项目):

前 5 个关键因素 → 决定了 72% 的排名差异
中间 15 个因素 → 决定了 23% 的排名差异
后 10 个因素 → 只影响 5% 的排名差异

业务上合理的做法是:对关键因素用强模型 + 完整 RAG 链路,对次要因素用小模型或规则

评分因素分级策略:

核心因素 (决定排名):
模型: Qwen3-70B 或 deepseek-chat
RAG: 完整链路 (Query Rewrite + 多路检索 + Reranker)
Agent: 完整的 PocketFlow 流水线
成本: 高, 但值得——这些因素决定谁中标

常规因素 (影响评分):
模型: qwen-turbo 或 claude-haiku
RAG: 简化检索 (向量检索 + TopK)
Agent: 直接 LLM 调用, 不走流水线
成本: 中

辅助因素 (形式审查):
模型: 规则 + 关键词匹配, 不用 LLM
Agent: 无
成本: 接近零

业务收益:同一个项目,总 Token 消耗可以降低 40-50%,但对最终排名准确性的影响控制在 2% 以内。省下来的成本可以投入到核心因素的优化上,形成正向循环。

方向四:Agent 效果的持续监控和告警——不做事后诸葛亮

Agent 上线后,效果不是一成不变的。我们遇到过的几个实际问题:

  1. 模型更新导致效果滑坡:底层 LLM 版本更新后,某个 Agent 的输出风格变了——原来会给 detailed reason,更新后只输出一句话。专家修改率从 15% 涨到 28%。
  2. Prompt 漂移:Prompt 模板经过多次修改后累加了冗余指令,Agent 的行为和最初设计有了偏差。
  3. 数据分布变化:某个季度投标人类型变了(比如多了很多新成立的公司),原有 Agent 对这类公司的评审效果明显下降。

解决办法是一套效果监控看板,核心指标:

每天自动出报表:

Agent 健康度仪表盘 (按天聚合):

Agent | 调用量 | 平均分 | 专家修改率 | 平均耗时 | Token 消耗 | 趋势
------------|--------|--------|-----------|---------|-----------|------
财务评审 | 1,234 | 3.82 | 17.1% | 52s | 4.2M | →
业绩评审 | 987 | 3.45 | 22.3% | 68s | 5.8M | ↑ (修改率上升!)
技术评审 | 756 | 3.91 | 12.5% | 91s | 8.1M | →

异常告警规则:
- 专家修改率连续 3 天超过基线 +20% → P0 告警
- 平均耗时超过基线 2 倍 → P1 告警
- Token 消耗环比增长超过 50% → 通知排查
- 某个评分因素的修改率突然飙升 → 定向排查该 Agent

监控不是为了看数据,而是为了驱动迭代。每次告警触发,走一个标准流程:

告警 → 定位 (哪个 Agent/哪个评分因素)
→ 分析 (Shadow Mode 跑新版/旧版对比)
→ 修复 (调整 Prompt 或换模型)
→ 验证 (Canary 10% 流量)
→ 全量发布
→ 监控效果 (确认修改率回落)

这个流程和之前说的 Eval 体系是同一套——离线测、Shadow 对比、Canary 灰度、线上监控专家修改率。区别在于,监控是 7x24 的,不是有人想改 Prompt 时才跑一次。

方向五:Agent 降级策略——不能因为 AI 挂了就让业务停转

这是最贴近业务的一个考虑。LLM 服务不是 100% 可用的——模型 API 超时、限流、内容审核拦截,我们都遇到过。如果 AI 评审不可用,专家就得全手工评标,效率大幅下降。

我们的做法是多级降级:

第一级: 模型降级 (几秒级)
deepseek-chat 超时 → 自动重试 2 次
→ 仍失败 → 切换到 claude-haiku
→ 仍失败 → 切换到 qwen-turbo
保证:只要还有一个模型可用,Agent 就能跑

第二级: Agent 降级 (分钟级)
完整 RAG 链路不可用 → 降级到简化方案
RAG 检索超时 → 跳过 RAG,直接把文档分段塞给 LLM
KV 提取失败 → 降级到全文直接提取
保证:Agent 能在功能裁剪后继续评审

第三级: 业务降级 (小时级)
所有 LLM 模型不可用 → 切换到"规则引擎模式"
系统根据预配置的评分规则做基本计算
输出附带标记"此评审由规则引擎完成,建议专家重点复核"
保证:系统不宕机,业务走得了

每级降级都对应一个业务指标的变化。一级降级用户无感知;二级降级 AI 评审质量可能下降,但评审结论仍可参考;三级降级只做最基本的规则判断,结论需专家全面复核。

这个策略在线上验证过效果——有一次 deepseek API 连续故障 40 分钟,我们的 Agent 自动降级到 claude-haiku,业务完全无感,用户甚至没发现模型切换了。如果当时没有降级策略,这 40 分钟里所有项目的 AI 评审都会失败,专家只能干等。


方向六:Agent Harness——缰绳工程,不是管道工程

这个方向我前面写错了,纠正一下。

Harness Engineering 不是"把 LLM 调用封装成公共库",也不是"工具调用协议或 MCP"。它是 Mitchell Hashimoto(Terraform 创始人)定义的缰绳工程——你给 AI 周围造的"缰绳"(规则、约束、反馈、记忆、编排)决定了它的实际表现,很多时候比模型本身的能力更重要。

LangChain 的实验最能说明问题:同一个模型(GPT-5.2-Codex),只调了一下周围的"缰绳"配置,成绩从 52.8% 涨到 66.5%,排名从 Top 30 跳到 Top 5。模型一行没改。

Harness 五组件 vs bid.ai 现状

Hermes 把 Harness 拆成五个组件,对照我们的系统来看:

指令层 (Instruction):
定义:告诉 AI 怎么做事 — CLAUDE.md、AGENTS.md、Skill 文件
我们的现状:
有 Prompt 模板(fetch_prompt 从配置中心拉取)
有 Agent 的系统指令(FinancialEvaluationPrompts)
问题:指令是静态的、一次性的。不会因为上一次犯过错就自动调整指令。
差距:没有"从错误中提炼指令"的机制。

约束层 (Constraint):
定义:限制 AI 不能做什么 — hooks、linter、CI、Tool permissions、sandbox
我们的现状:
有超时控制(Activity 30s/2h timeout)
有沙箱执行(subprocess 代码执行)
有 Token 长度阈值(110000 字符保护)
问题:约束是分散的、手工配置的。不会因为新发现的错误类型自动增加约束。
差距:没有"犯过错→加一条约束"的渐进积累过程。

反馈层 (Feedback):
定义:错了能发现和修正 — 审查、评估、自改进循环
我们的现状:
有专家复核(review → modify → confirm)
有 Eval 测试集(离线跑 case)
问题:反馈链是断裂的。专家修改了 AI 的评分,但修改本身没有变成 AI 的学习素材。
差距:没有"犯错→记录→提炼→不再犯"的闭环。这是最核心的缺失。

记忆层 (Memory):
定义:跨会话记住经验和教训 — 三层记忆(会话/持久/Skill)
我们的现状:
有任务缓存(FinancialEvaluationProject 缓存预处理结果)
有 PointKV 结构化数据存储
问题:存的是"当前项目的数据",不是"从历史中学到的经验"。
差距:没有长期经验记忆。项目 A 的评审经验不会自动迁移到项目 B。

编排层 (Orchestration):
定义:协调多步骤/多 Agent — 子 Agent 委派、Pipeline、cron
我们的现状:
有 PocketFlow 三阶段流水线
有 Temporal 工作流编排
问题:编排是固定的、预定义的。不能根据任务难度动态分配资源。
差距:没有动态编排能力。

最应该先做的是反馈层——把"专家修改"变成 Agent 的学习信号

五个组件里,投入产出比最高的是反馈层。因为其他四个组件都可以手动搭,但反馈层一旦建立,驱动的是自动改进——Agent 自己从错误中学习,不需要人每次介入。

具体做法:

1. 每次专家修改 AI 评分,自动生成一条教训(Lesson)。 数据结构:

Lesson = {
"id": "L-2025-12-15-001",
"agent": "finance_agent", # 哪个 Agent
"factor": "资产负债率", # 哪个评分因素
"trigger": "投标人提供了应付债券数据", # 触发场景
"error": "AI 未将应付债券计入负债", # 什么错误
"rule": "资产负债率计算必须包含应付债券", # 提炼的规则
"expert_action": "修改分数 + 添加备注", # 专家怎么纠正的
"status": "active",
}

这个教训不只是存着。下次同类评审时,Harness 自动检索相关教训,作为 System Prompt 的一段注入 Agent 上下文。

2. 维护一个活的约束文件。 这个文件像 CLAUDE.md 一样放在项目根目录或 Agent 配置目录,由系统自动维护:

# Agent 约束清单 (自动维护)

## 评审规则 (来自专家修改记录, 共 23 条)
- 资产负债率计算必须包含应付债券 (added: 2025-12-15, 修改率 -4.2%)
- 在建工程不算已完工业绩 (added: 2025-12-20, 修改率 -3.1%)
- "建议提供"不等同于"必须提供" (added: 2026-01-08, 修改率 -2.8%)

## 常见错误模式 (自动识别, 共 7 种)
- 负数忽视:净利润为负时 AI 给分偏高 (命中 12 次)
- 模糊表述误判:投标人写"暂无"被误判为"未提供" (命中 8 次)

这个文件的好处是:可审计、可追溯、可人工修正。哪条规则效果不好,人工可以改或删。而且每个规则后附带效果指标(修改率变化),能看出这条规则实际有没有用。

3. PocketFlow 节点增加"复盘"步骤。 每个节点执行完,不只传数据给下个节点,还多做一个动作:

# 当前:执行 → 输出
def post(self, shared, prep_res, exec_res):
shared['extracted_data'] = exec_res
return 'default'

# 改造后:执行 → 输出 → 复盘
def post(self, shared, prep_res, exec_res):
shared['extracted_data'] = exec_res

# 复盘:这次执行有什么值得注意的?
lessons = self.extract_lessons(prep_res, exec_res)
if lessons:
shared['lessons'].extend(lessons)
return 'default'

复盘不是每次都要写教训——只有发现异常、边界情况、或者和预期有偏差时才记录。正常流程走完不需要额外操作,不影响性能。

总结:Harness Engineering 在 bid.ai 的落地,核心是建立"犯错 → 记录 → 提炼 → 不再犯"的闭环。LangChain 的实验证明,调整缰绳比换模型效果更明显。我们对标五个组件,最薄弱的是反馈层——专家修改了 AI 的评分,但修改本身没有成为 Agent 的学习信号。把这件事做了,Agent 的质量会随着使用时间自然提升,而不是永远靠人工调 Prompt。

总结方法论层面的优化思路,核心是四个字:分层分治

  • 评分因素分层:核心因素走完整链路,次要因素走简化方案——聚焦资源于真正决定排名的地方
  • 成本分层:强模型做关键判断,弱模型做常规判断,规则做形式审查——每一分钱都花在刀刃上
  • 降级分层:模型→Agent→业务,逐级兜底——AI 挂了业务不能停
  • 迭代分层:离线 Eval → Shadow → Canary → 全量 + 7x24 监控——每次改动都知道效果,出问题能及时发现

Agent 不是"写好 Prompt 上线就行了"的东西。它是一个需要持续运营的系统——监控、迭代、降级、分层,缺一环都不行。

问题:你们用到 tool 和 MCP 和 skills 了吗

实话实说——没有。这三个概念对应三种不同的能力,我们目前一个都没用上。

先说情况,再说原因,最后说如果现在要加我会怎么加。

Tool:没有正式的工具调用协议

我们有一些"工具"的功能,但没有工具调用的标准协议。具体来说:

代码执行工具:在 CodeGenerationNode + CodeExecutionNode 里用 subprocess 沙箱执行 Python 代码(bid.ai/app/agent/worker/finance/runtime/agent_processor.py)。这个实现了"模型生成代码 → 执行 → 拿结果"的循环,但问题是——代码不是模型主动"调用"的,而是 PocketFlow 编排好的固定节点。模型没有选择"我要不要调代码执行器"的自由。

知识检索工具:RAG 检索和 KV 提取可以看作知识工具。但同样——检索是预编排的流程,不是模型在推理过程中发现自己缺信息然后主动发起的检索。

这中间的区别是:预编排模式适合评审这种确定性流程(每个评分因素都要走同样的步骤),但限制了模型的自主性。真正的 Tool-Use 应该是模型在推理过程中自己决定"我现在需要查一下这个公司的历史数据"然后调工具。

如果要加 Tool 支持,我计划在 Harness 层加一个 Tool Registry:

class Tool:
name: str
description: str
parameters: dict # JSON Schema
execute: callable

class ToolRegistry:
def register(self, tool: Tool): ...
def list_tools(self) -> list[Tool]: ...
def execute(self, name: str, args: dict) -> Any: ...

# 预注册的工具
registry = ToolRegistry()
registry.register(Tool(
name="code_executor",
description="执行 Python 代码并返回结果",
parameters={"code": {"type": "string"}},
execute=run_code_in_sandbox,
))
registry.register(Tool(
name="kv_retrieval",
description="检索投标文件中的关键数据",
parameters={"query": {"type": "string"}, "company": {"type": "string"}},
execute=kv_client.search,
))

然后在 Agent 的 System Prompt 里声明可用工具列表,让 LLM 在需要时自己决定调哪个。目前 Agent 的 Prompt 是固定流程,改成 Tool-Use 模式后可以更灵活。

MCP:完全没有涉及

MCP(Model Context Protocol)是 Anthropic 在 2024 年 11 月 25 日 提出的标准协议,定义模型和外部工具/数据源之间的交互规范。我们的项目里没有任何 MCP 的集成——没有 MCP server,没有 MCP client,也没有用任何 MCP 兼容的工具。

从时间线来看:MCP 发布是 2024 年 11 月,项目核心开发启动在 2025 年中。到 2025 年下半年开始做架构决策时,MCP 才发布了半年左右,生态还在早期——主要模型(Claude、GPT、Gemini)对 MCP 的原生支持还没落地,社区的 MCP server 数量有限,业界对这个协议是否会被广泛采用还在观望。那时候做架构选型,MCP 远不是一个"必须考虑"的因素。

真正让 MCP 成为主流要到 2025 年底到 2026 年初——主要模型开始原生支持 MCP,各大厂商(Google、AWS、Microsoft)陆续宣布兼容,社区 MCP server 数量从几百涨到几千。到这个时间点,我们的 Agent 架构已经定型了。

所以客观地说:不是"没选 MCP",而是"MCP 成熟的时间点晚于我们的架构决策窗口"。

我们的工具调用走的是私有协议:Agent 直接调 Python 函数(code_executor)、直接调 HTTP API(KV client、Prompt fetch)。没有走 MCP 那套 discovery + invocation 的标准流程。

主要原因有三:

Skills:没用这个概念

"Skills"在我们的项目中不是一个正式的抽象。项目里没有 skill 注册、skill 发现、skill 组合的机制。

从时间线看:Anthropic 的 Agent Skills 在 2025 年 10 月 16 日 才正式发布,作为一个开放的文件夹式标准(SKILL.md + 脚本 + 资源),到 2025 年 12 月 18 日 才发布为开放标准(agentskills.io)。项目最核心的 Agent 架构决策发生在 2025 年中到第三季度,Skills 发布的时候我们的 Agent 流水线已经在线上跑了。

而且 Skills 刚推出时主要面向 Claude Code 和 claude.ai,和我们的技术栈(Litestar + Python 后端 + Dify Workflow)没有直接关系。Skills 作为一个跨平台标准是后来才发展的。

原因在于项目的架构设计思路和 skills 不同: 总结:Tool、MCP、Skills 我们目前一个都没用。Tool-Use 被 PocketFlow 的固定编排替代了,MCP 因为项目启动时生态不成熟没引入,Skills 不是我们的架构模式。但我们有一些对等的设计——subprocess 代码执行约等于 Tool、Dify Workflow 约等于工具编排、PocketFlow Node 约等于粗糙的 Skill。

如果要进化,路线是:Tool Registry(标准化工具调用)→ MCP 封装(标准化协议)→ Skill 化(模块化能力单元)。三步逐步推进,不推翻现有架构。

问题:你的 RAG 怎么设计 chunk 策略的,选用什么向量库,为什么?有没有重排序等优化,怎么解决幻觉问题

这个题我分四块说吧:向量库选型、chunk 策略、重排序、幻觉防御。每块我都说说我们怎么做的,和为什么这么做。

先说向量库:我们选的 Milvus

市面上向量库选项挺多的,我们当时主要对比了 Milvus、Elasticsearch 的向量插件、Qdrant 这三个。

先说结论,我们选了 Milvus。原因是这样的:

第一,我们数据量不算小。一个项目少说几十份标书,每份几百页,切完 chunk 之后大几万条是有的。而且项目是持续增长的,半年跑下来总量奔着千万级去。这种体量下,Chroma 那种单机方案扛不住,Pinecone 这种 SaaS 费用又太高。Milvus 天然支持分布式扩展,数据量大了加节点就行。

第二,支持混合检索。我们的场景很需要这个——向量检索搜语义,BM25 搜专有名词,Milvus 原生支持这两种检索方式,不需要我们搭两套系统再自己写融合逻辑。这方面 Qdrant 其实也做得不错,但我们当时选型的时候 Milvus 的社区生态更成熟一些,Kubernetes 部署方案、监控、备份这些运维工具链也更完善。

第三,索引类型丰富。Milvus 支持 IVF、HNSW、PQ 好几种索引。我们实际线上用的是 HNSW,在精度和速度之间平衡得最好。M 参数设的 16,efConstruction 设的 200,efSearch 设的 64。这几个参数是我们在测试集上网格搜索扫出来的——efSearch 从 32 提到 64,召回率能涨 2-3 个点,再往上就边际递减了。

其实 ES 的向量插件我们也考虑过,毕竟团队对 ES 比较熟。但测下来发现 ES 的向量检索性能在数据量大了之后下降比 Milvus 明显,而且它的混合检索(向量 + 关键词)做得不够灵活,权重不好调。所以最终还是选了 Milvus。

Chunk 策略:不搞一刀切,按文档类型走不同策略

chunk_size 和 chunk_overlap 这种参数没有银弹,我们针对不同类型的文档用了不同的路子。

评审标准、法规这类静态文档,我们按 Markdown 标题层级来切。因为这些文档结构很清晰,一条评审标准就是一个独立单元,按固定 token 数切反而会把一条完整标准截成两半。做法是先用文档解析把 Word/PDF 转成结构化 Markdown,然后基于标题层级(# → ## → ###)做递归分割,每一块就是一个完整的评审条款。这样 Agent 检索到的每一个 chunk,本身就是一个可以独立判断的依据,不需要拼凑上下文。

招标文件和投标文件,这个更复杂一点。一份标书几百页,有正文、有表格、有附件。我们的做法是两层:

第一层,先把文档按章节结构切成"父块",每个父块对应一个章节。父块不直接用于向量检索,它太大,语义不聚焦。

第二层,在每个父块内部,用递归字符分割切成"子块",chunk_size 设的 512 token,overlap 设的 64(约 12.5%)。子块才是真正去做 embedding 和检索的。

检索的时候,命中的是子块,但我们返回的是子块所属的父块全文。这个其实就是父子文档分块(Parent-Child Chunking)的思路——用小块保证检索精度,用大块保证生成上下文的完整性。

chunk_size 512 这个值不是拍脑袋定的,我们在测试集上扫过一轮。256 以下的块信息量不够,Agent 经常说"无法判断";1024 以上的块噪声太多,命中率反而下降。512 是个平衡点。

两个贴合业务场景的优化

除了上面这些常规策略,我们还做了两个贴合招投标场景的设计:

优化一:Key-Value 预提取 + KV 级检索

这个最有价值。招投标评审有一个特点——评审的其实是"值"而不是"文"。什么意思呢?专家关心的是"资产负债率是多少"而不是"财报第三段写了什么"。所以我们不走"全文切 chunk → 检索 → Agent 自己提取"这条路,而是在 chunk 之前先做一层 KV 提取。

先让一个专门的 KV Agent 把标书里的关键信息提成结构化键值对,比如 asset_liability_ratio: 68.5%registered_capital: 1.2亿元。这些 KV 对本身就是一个个"超级 chunk"——它们比纯文本 chunk 更精确、更聚焦。

检索的时候,Agent 同时查两路:一路是常规的文本 chunk 检索(用于找上下文),一路是 KV 检索(用于拿精确数据)。事实上在业绩评审场景里,大部分判断靠 KV 检索就够了,文本 chunk 检索更多是作为补充校验。

优化二:评分因素驱动的 Query 重写

常规 RAG 里 Query 重写是通用的——用户问什么就改写什么。但我们的场景不太一样。Agent 收到的查询不是一个自由问题,而是一个评分因素,比如"技术方案完整性"。直接拿这个词去检索投标文件,效果很差,因为投标文件里不会出现"技术方案完整性"这个短语。

我们做了一个基于评分标准的 Query 重写器。每个评分因素都配了评审标准文本(factor.standard),比如"投标人需提供项目管理计划、人员配置表、进度安排"。重写器不是原封不动用这个标准,而是把标准拆成多个检索 Query:

  • 标准:"投标人需提供项目管理计划、人员配置表、进度安排"
  • 重写后 Query1:"项目管理计划 实施方案"
  • Query2:"人员配置 项目团队组织结构"
  • Query3:"项目进度 甘特图 里程碑节点"

这三个 Query 各有侧重,分别检索后合并结果。效果上,加了这层重写之后,检索命中率提升了大概 8 个点。原因很简单——评分标准的表述是"要求",投标文件的表述是"承诺",两者用词不一样,不重写就对不上。

重排序:两级精排

重排序我们走了标准的两阶段路线。

第一阶段是多路检索后的 RRF 融合。我们同时跑向量检索和 BM25 检索,合并后用 Reciprocal Rank Fusion 做粗排。RRF 的好处是不依赖不同路之间的分数可比性,只看排名,被两路都命中的 chunk 自然排到前面。这一步从 top-50 筛到 top-15。

第二阶段用 Cross-Encoder 做精排。我们线上挂了 Qwen3-Reranker-0.6B,这是个轻量级的 reranker,部署在 vLLM 上。它把查询和每个候选 chunk 拼在一起做深度交互打分,比向量相似度的精度高很多。精排后取 top-5 送入 Agent。

加上 Reranker 之后,Recall@5 从 79% 提升到 86%,MRR 从 0.58 提升到 0.81。提升最明显的主要是模糊匹配场景——投标文件和招标要求用词不一致的时候,向量检索可能把不相关但用词相似的 chunk 排到前面,Reranker 能纠正这种误判。

幻觉防御:四层递进,外加人工兜底

这个我们真的花了不少精力。评审场景和客服问答不一样,错一个数字可能影响几千万的标的。我们的防幻觉体系是四层递进的:

第一层:Agentic RAG 多轮反思。 Agent 不是检索一次就生成答案,而是走"检索 → 回答 → 反思 → 再检索 → 再回答"的多轮循环。反思环节 Agent 自己判断"当前答案够不够完整?有没有遗漏关键信息?不够就换角度再查一次"。这个设计天然降低了"一次检索漏了关键信息导致幻觉"的风险。

第二层:Split → Rethink 判断模式。 评审的时候,Agent 不是直接给结论,而是先把多个审查项拆开逐个判断(Split),再汇总起来做一次全局复核(Rethink)。比如业绩评审里,先把 5 项业绩逐条判断是否有效,每条判断都引用对应的招标条款做依据;然后汇总 5 条结果,让 Agent 做一次一致性检查——有没有前后矛盾的判断?有没有漏判的?这个"先拆后合"的模式,比一次问完的幻觉率低很多。

第三层:引用强制溯源。 Agent 输出的每个评分结论,都必须附带引用——引用了哪个 chunk、原文内容是什么、在文档的什么位置。我们在 Prompt 层面就约束了输出格式:reference_doc_ids 字段必须非空,如果 Agent 输出了一个没有来源的结论,我们会判为无效输出,要求重试。这个机制的好处是——即便 Agent 编造了内容,造出来的引用 ID 在已检索的 chunk 里找不到对应,系统可以检测到。

第四层:Prompt 约束 + 温度控制 + 兜底回答。 这层是常规操作但也很重要。Prompt 里明确写"只能基于检索到的内容回答,检索不到的信息不要编造"。temperature 设 0.1,降低随机性。如果检索结果的相似度都低于阈值,直接返回"未找到相关信息",不让 Agent 硬编。

然后最后一层是人工复核,这个是终极防线。所有 AI 评审结果必须经过专家确认才能生效。Agent 写 bid_evaluations 表,专家写 expert_evaluations 表,两张表隔离。专家的每一次修改记录本身就是反馈——哪个评分项 AI 最容易出错的,用这个数据指导下一轮优化。

线上跑下来的数据:加上 Agentic RAG 多轮循环后,反事实准确率从 68% 提到 87%;加上引用溯源和人工复核,严重幻觉率控制在 5% 以下,而且所有误判都能在复核阶段被发现。

但说实话,RAG 没有完全消除幻觉。我们遇到过 Reranker 把不相关 chunk 排到前面导致 Agent 引用了错误内容、多轮反思时 Agent 编造了"看起来合理但不存在"的信息、以及最典型的问题——招标要求和投标文件高度相似时 Agent 混淆了两者说反了。这些都会被专家复核发现并修正,所以我的结论是:RAG 可以把幻觉降到可控范围,但最后的防线一定是人。在评审这种严肃场景里,AI 永远是辅助角色。

问题:围串标预警怎么做的

围串标预警是我们整个评标流程的第一个环节,叫 AI_WARNING 阶段。它的定位很简单——在正式开始评分之前,先把那些明显有问题的投标人过滤掉。说白了就是个"入场资格审查",你要么合格进来参与评分,要么直接判定无效,后面商务、技术、价格评审根本不会看到你。

我分三块来说:预警到底查什么、怎么执行的、查完之后结果怎么处理。

先说查什么:三类检查

第一类是围串标嫌疑相关的核心检测。这里面包括几个维度:有没有围标串标的嫌疑、联合体信息有没有问题、投标人有没有在集团的黑名单或者失信名单里。这几项是 AI 预警阶段最核心的检测项。

第二类是废标条款审查。这个其实就是对标《招标投标法》里规定的那些废标情形,逐条过一遍。比如投标人名称前后一不一致、签字盖章有没有漏、资质等级够不够、项目经理的资历符不符合要求、有没有挂靠嫌疑、投标保证金有没有按时交、工期和质量标准有没有实质性响应……大概十几二十项。其实传统评标里专家也要做这些事儿,我们只是用 Agent 先过一遍。

第三类比较特殊,是项目级的预警 Agent。我说的"项目级"意思是它不按投标人一个一个跑,而是拿整个项目的数据一次跑完。典型的有两个:一个是 错别字异常预警——检测各家投标文件里有没有相同的模板残留或错别字。你想想,如果 A 公司和 B 公司的标书里都把"招标"错写成"招示",而且错得一模一样,那就很有问题了。另一个是 分项报价雷同预警——对比各家投标人的分项报价明细表,如果精确到小数点后两位都一模一样,这种高度雷同基本可以判定串标。

从代码里可以清楚看到这两类 Agent 的区分:

# 项目级 Agent 定义
special_project_level_agents = {
'企业财务状况', '报价评分',
'错别字异常预警', '分项报价雷同预警'
}

"错别字异常预警"和"分项报价雷同预警"在这个集合里,说明它们是项目级的,整个项目只跑一次。其他不在这个集合里的 Agent,每个投标人都要单独跑一遍。

再说执行流程:rules 驱动,不是硬编码

说句实话,围串标预警这个功能最让我满意的不是 AI 检测的逻辑有多强,而是它的执行引擎是可配置的。不是说"我们要加一项检测"就去改代码、重新部署,而是每个检测项是一条 rule 配置,存在数据库里。

具体流程是这样的:

项目创建之后,业务人员配置评分因素表。每个评分因素关联一个 agent_name,比如"AI预警-围串标预警"。

然后 ProjectService 会把这些评分因素打包成一个叫 rules_to_run 的配置,里面包含了要执行哪些检测、每个检测用什么 Agent、评审标准是什么。

接着 Temporal Workflow 启动,走到 non_technical.py 这个文件,里面有个函数叫 prepare_non_technical_evaluation_jobs_impl,它的工作就是读取 rules_to_run,筛选出 AI_WARNING 阶段的规则,然后逐条创建执行 Job。

每个 Job 最终会调用一个 Dify Workflow。Dify Workflow 里跑的是 LLM Agent,Agent 拿到入参后去分析投标文件,给出判定结果。

这里有个重要设计——幂等性。每个 Job 都生成了一个 idempotency_key,如果同一个 Job 因为网络抖动或者 Worker 重启被调度了两次,第二次执行时会直接跳过,不会重复调 LLM。

Workflow 的入参大概是这样的:

{
'project_id': 项目ID,
'company_name_list': '公司A-公司B', # 所有投标人列表
'key': '围串标预警',
'factor_name': '围串标预警',
'standard': 评审标准文本,
'stage_name': 'AI_WARNING'
}

项目级 Agent 只需要一个 Job 就能处理所有投标人;普通 Agent 每个投标人创建一个独立的 Job。

判定逻辑:Agent 到底看什么

具体的判定逻辑是 Dify Workflow 里的 LLM Agent 执行的,不是写死的规则。但根据我们的检测项设计和业务理解,Agent 判断围串标主要看这几个维度:

文件雷同度。对比各家投标文件的正文内容,如果"公司简介"部分一字不差、技术方案的结构和措辞完全一致,这些都是围串标的典型特征。正常来说,不同公司写的标书不可能一模一样。

价格异常模式。分析报价之间有没有规律性的关系。比如 A 公司的报价总是比 B 公司高 3%,或者多家投标人的报价分项明细出现了完全相同的计算错误——这个很关键,因为正常独立报价不可能犯一模一样的错。

模板残留和元数据。检查 doc 属性的作者信息、创建时间,看不同标书是不是出自同一人之手。还有错别字检测——前面说了,相同的错别字出现在不同公司的标书里,基本实锤。

分项报价雷同。这是专门对报价明细表做的检测,刚才已经说过了。

联合体交叉。检查不同投标人的联合体成员有没有交叉。比如 A 公司和 B 公司分别投标,但 A 公司同时出现在 B 公司的联合体成员名单里,这明显违规。

黑名单和失信记录。通过外部接口查询投标人是否在集团的黑名单或者失信被执行人名录里。

结果怎么处理的

Agent 检测完之后,结果写回 BidEvaluation 表,阶段标记为 AI_WARNING。每条记录的格式是:哪个投标人、哪个检测项、结果是通过还是不通过、AI 的评审意见是什么。

综合判定逻辑很简单——如果某个投标人所有检测项都通过,就是合格投标人;只要有一项不通过,判定为无效投标。这个逻辑在 get_ai_warning_statistics 这个函数里实现的,它就是查数据库,数一下每个投标人有没有被标记为"不通过"的记录。

前端 AiWarning 页面展示汇总结果,大概长这样:

本次预警累计核查【5】家投标人,
最终判定合格投标人【3】家:公司A、公司B、公司C;
无效投标【2】家:公司D、公司E。

另外也支持按投标人名称和检测项筛选查看详情。

最关键的是,判定为无效的投标人不会进入后续评审。Temporal Workflow 在进入下一阶段前会检查 AI_WARNING 的结果,无效投标人的评分记录被标记为不可用,商务、技术、价格评审 Agent 自动跳过。这样也省了后面不必要的 LLM 调用。

同时预警结果会流入专家评审界面,专家可以点开看每项检测的依据,确认或修正 AI 的判断。

总结

围串标预警其实不是一个写死的规则引擎,而是一套 rules-driven 的 Agent 执行系统。核心文件是 non_technical.py,它通过遍历规则配置、创建执行 Job、调用 Dify Workflow,让 LLM Agent 对每个投标人做多项检测。我想说的是,这套机制最有价值的点是可扩展——想加一项新的检测,只需要加一条 rule 配置和一个 Dify Workflow,完全不用改核心代码。

问题:不同评标项目之间的数据怎么隔离

数据隔离这事我们不是从"安全"出发的,而是从"业务"出发的——评标项目之间本来就是天然隔离的,项目 A 的专家没道理看到项目 B 的标书。所以整个系统在设计上做了好几层隔离,从存储到数据库到知识库到权限,每一层都有各自的隔离手段。

先说第一层:存储层的文件隔离

投标文件和招标文件都存在对象存储里,本地开发用 MinIO,生产走华为 OBS。文件路径的格式是带项目 ID 前缀的:

  • 招标文件:bid_files/{project_id}/{uuid}_{filename}
  • 投标文件:tender_files/{project_id}/{bidder_id}/{uuid}_{filename}

你看这个路径结构,project_id 是第一级目录。就算存储层没有权限控制(比如本地 MinIO 开发环境),文件也已经被物理地分隔在不同的目录下了。生产环境的 OBS 会配桶级别的访问策略,进一步加固。

这个设计还有一个好处——项目结束后清理数据很方便,直接删 bid_files/{project_id} 目录就行了,不用逐个文件去匹配。

第二层:数据库的行级隔离

这个最直接。几乎每张业务表都有 project_id 字段,而且加了索引。所有的业务查询都强制带 project_id 条件。你看业务代码里,不管查什么都是这个模式:

query = select(BidEvaluation).where(
BidEvaluation.project_id == project_id,
BidEvaluation.stage_name == stage_name,
BidEvaluation.deleted_at.is_(None),
)

project_id 是必传参数,如果哪个接口漏了传 project_id,要么查不到数据(因为默认值不匹配任何记录),要么报错。我们在索引设计上也做了保障——关键查询都有复合索引来覆盖,比如 (project_id, stage_name)(project_id, bidder_id)(project_id, stage_name, bidder_id)。这样加 project_id 条件对查询性能影响很小。

表之间也不走外键约束,前面说过这是我们项目的设计原则。关联一致性在应用层保证——创建 BidEvaluation 记录的时候,代码里显式传 project_id,不会出现"这个 BidEvaluation 属于哪个项目"的歧义。

第三层:知识库隔离(动静分离的双层隔离)

这个回到之前我说的 RAG 动静分离设计。我们把知识分成静态和动态两类,隔离策略也是基于这个分类来做的。

动态知识(招标文件、投标文件) 是按项目隔离的。每个项目创建的时候,系统会初始化一套独立的向量索引。项目 A 的标书切完 chunk、做完 embedding,写到项目 A 专用的索引空间;项目 B 的标书写到项目 B 的索引空间。检索的时候,Agent 只查当前项目绑定的索引,根本搜不到其他项目的内容。

这个"索引空间"的隔离是通过向量库的 collection/partition 机制实现的。我们用的 Milvus,一个项目对应一个 collection,collection 级别的隔离保证了数据和查询都不会跨项目。即便应用层的 project_id 过滤漏了,向量库层的 collection 隔离也是一道独立的防线。

静态知识(评审标准、法规、历史案例) 则是共享的。因为评审标准是全集团统一的,不需要也不可能每个项目建一套。这些静态知识集中在一个共享的 collection 里,所有项目都可以检索。但检索的时候会带上项目类型作为过滤条件——比如工程类和货物类的评审标准不一样,通过标签过滤确保只返回跟当前项目类型相关的标准。

所以知识库隔离不是"一刀切"式的完全隔离,而是动静分离的双层策略:动态知识按项目物理隔离(独立 collection),静态知识共享但逻辑过滤(标签 + 元数据过滤)。这样既保证了项目间的数据安全,又避免了评审标准重复索引造成的存储浪费。

第四层:用户权限隔离

这一层决定"谁可以看哪个项目"。不是每个注册用户都能看到所有项目。

我们用了一张 ProjectExpert 表来维护专家和项目的映射关系:

class ProjectExpert(AsyncModel, table=True):
project_id: int # 关联项目
bid_user_id: str # 用户ID
expert_name: str # 专家姓名
expert_role: str # 专家角色

每次专家请求项目数据的时候,ExpertAuth.check_expert_permission 会检查这个用户是不是这个项目的专家。如果不是,直接返回 403。这个检查是在接口层做的,所有需要鉴权的接口都会调这个函数:

await ExpertAuth.check_expert_permission(session, project_id, user_id)

这样即使前端不小心把项目 B 的 ID 传给了后端,后端也会因为权限校验不通过而拒绝返回数据。

系统管理员层面也有隔离——不同类型的用户只能看到自己权限范围内的项目列表。这些通过 UMS(统一管理系统)的 user/role/permission 体系来控制。

第五层:评审任务隔离

除了数据层的隔离,执行层面也有隔离。每个项目创建一个独立的 TenderTask 记录,这个任务包含了该项目所有的评审配置——哪些评分因素、哪些投标人、用哪些 Agent。Temporal Workflow 启动的时候以项目为粒度,一个 Workflow 只处理一个项目。

即使两个项目同时触发 AI 评审,它们的 Temporal Workflow 也是独立的,各自读取各自的 TenderTask 配置,各自写入各自的 BidEvaluation 记录,完全不会互相干扰。这种隔离在并发场景下特别重要——生产环境同时跑几十个项目的评审是常态,不能因为数据隔离没做好导致项目 A 的评分写到项目 B 去了。

总结一下:五层隔离,每层解决不同的问题。存储层用路径前缀做物理隔离,数据库层用 project_id 做行级逻辑隔离,知识库层用独立 dataset 做检索隔离,用户层用 ProjectExpert 做权限隔离,执行层用独立的 Temporal Workflow 做任务隔离。哪一层都没法单独做到 100%,但五层叠在一起,数据出问题的概率就极低了。

问题:你们怎么管理上下文窗口的,prompt 每部分分别占多少,超出模型上下文大小怎么办

这个问题在评审场景里特别现实,因为投标文件动不动几百页,随便一篇财报就十几万字符,而模型的上下文窗口是有限的。我们的做法不是一刀切,而是针对不同场景做了不同策略。我分三块来说:prompt 结构设计、上下文分配的考量、超出时的降级策略。

先说 prompt 结构

以我们用的最多的 kv-ragent 的 prompt 为例,它大概分这么几块:

第一块是角色定义,大概几十个 token。就是告诉模型"你是专业的招投标文档问答助手",这部分基本固定。

第二块是任务说明输出格式,加起来大概两三百 token。规定了模型要做什么、输出必须是什么 JSON 结构。这块卡得比较死,因为下游代码要解析 JSON。

第三块是问题(question),这个看情况,一般很短,几个到几十个 token。

第四块是回答要求(description),几十到几百 token。这部分是对检索目标和输出方式做进一步约束,比如"请从招标文件第三章查找相关要求"或者"如果有多个条款请完整罗列"。

第五块是参考引文(context),这个是占大头的地方。Reranker 精排后 top-5 的 chunk 拼进来,每块少则一两百 token,多则上千。这块的尺寸直接影响上下文够不够用。

第六块是注意规则,类似于引用规范、禁止事项之类的指令,大概两三百 token。

所以整个 prompt 下来,角色+任务+输出格式大概三四百固定部分,问题+要求灵活但通常不大,真正的变量是 context 那块——取决于检索出来的 chunk 数量和长度。

上下文分配的真实情况

我们其实没有精确地去算"system prompt 占百分之几、context 占百分之几",因为我们面临的瓶颈不是 prompt 内部的比例,而是"top-5 chunk 就已经把窗口占满了"这个现实问题。

拿我们用的主要模型来举例,如果模型上下文是 32K,那扣掉系统指令和输出格式这些固定开销,实际给 context 的空间大概 30K 左右。top-5 的 chunk 加起来如果超过 30K,就得在 chunk 数量或长度上做取舍。

我们实际的做法是:宁可牺牲 chunk 数量也不牺牲 chunk 质量。Reranker 精排后取 top-5,但如果 top-5 加起来超了上限,我们会尝试先减数量——从 top-5 减到 top-3。如果 top-3 还是超(说明 chunk 本身很长),才会对单个 chunk 做截断,保留头尾关键部分。

这个取舍逻辑是这样的:从消融实验数据看,top-5 和 top-3 的 Recall 差距大概 3-5 个点,但如果因为 top-5 塞爆上下文导致模型输出截断或幻觉,损失远大于 3 个点。

超长文档的策略:先降级,不硬塞

对于超长的文档,我们不硬塞。具体的策略分几档:

第一档:全文直接给模型(适合短文档)。 如果文档长度在模型上下文窗口的安全范围内,比如财报不超过 11 万字符,就直接把全文作为 context 送给模型。代码里可以看到这个阈值判断:

if len(financial_report) < 110000:
result = self._invoke_json(prompt, params)

第二档:超过阈值,走 RAG 检索。 如果文档超长——代码里标识的是"110000 tokens"(实际是字符数)——就不直接塞全文了,而是降级走 KV 检索。具体做法是:用更轻量的检索方式从超长文档中找到相关片段,只把相关片段作为上下文,而不是整篇硬塞。

代码逻辑很直接:

if len(financial_report) < 110000:
# 全文直接给模型
result = self._invoke_json(prompt, {'financial_report': financial_report})
else:
# 降级到 KV 检索,只取相关片段
result = self._extract_financial_data_via_kv(
project_id=..., company_name=..., target_file=...,
)

这个设计背后的思路是:一篇 20 万字符的财报,AI 评审真正关心的指标可能就十来个——资产负债率、净利润、现金流…… 把整篇财报塞进去,模型要在海量信息里找这几个值,反而是噪声大于信号。走 KV 检索先定位到相关段落,上下文质量更高,虽然多了一次检索调用,但最终效果更好。

第三档:多层摘要。 如果文档超长而且找不到精确的 KV 匹配(比如需要全文语义理解的技术方案评审),就走分层摘要。先把文档按章节切段,对每段做独立摘要,再把所有摘要拼起来作为 context。如果摘要还是超长,再对摘要做二次摘要。

这个分层摘要的思路有点像 RAG 里的"父文档检索"——用小窗口命中具体内容,用大窗口提供完整上下文。只不过这里换成了摘要维度:第一次摘要保留细粒度信息,二次摘要保留粗粒度的整体脉络。

第四档:模型降级。 如果前面的策略都用上了,还是超了模型上下文——比如遇到特别复杂的评审场景——就通过 LiteLLM 自动切到支持更长上下文的模型。我们的模型链配置了降级策略:首选模型(比如 32K 窗口)失败后自动切到 128K 甚至 200K 的模型。代价是响应速度会慢一些,但至少不会因为上下文不够而失败。

Prompt 版本管理

还有一个值得一提的——我们的 Prompt 不是硬编码在代码里的,而是通过 PromptManager 从 YAML 文件加载。每个 Prompt 定义包含 modeltemperaturemax_tokenstemplate 这些字段,还可以指定 extraction_mode(是直接调 LLM 还是走 KV 检索)。

bid_scope.v1.yaml 为例:

prompt_key: bid_scope
version: v1
extraction_mode: kv
kv_question: "第一章中项目概况与招标范围是什么?"
kv_file_type: tender

extraction_mode 这个字段挺关键的——它决定了这个字段的填充方式。如果设成 kv,系统就知道这个字段内容太长,不能直接塞 prompt,要先通过 KV Agent 做一次检索提取。这其实就是在 prompt 设计层面做了上下文的预控制:那些明显会超长或者需要精确抽取的内容,不走直接 LLM 调用,走 RAG 链路。

总结一下

上下文管理不是单纯算 token,而是从数据输入到 prompt 结构到模型降级的全链路设计。数据层用 11 万字符阈值控制是否直接喂全文;检索层用 top-5 精排 + chunk 数量取舍控制 context 尺寸;遇到超长文档走 KV 检索或分层摘要;还不行就模型降级。最核心的思路就一条——不要硬塞,超出上下文的内容,换一种方式去获取,比截断或丢失信息要好得多。

问题:codeAgent 怎么设计的,怎么保证安全和权限

Code Agent 是我们财务评审 Agent 里的一个子模块,专门处理评分公式的计算。为什么需要它呢?因为招投标的价格评分通常是公式驱动的——比如"报价低于基准价 1% 扣 0.5 分,最多扣 10 分"——这种计算用 LLM 直接出分非常不靠谱,模型经常算错而且每次结果不一样,不可复现。

所以我们的做法是:让 LLM 生成评分计算的 Python 代码,然后在沙箱里执行这段代码,拿到精确结果。这样既保留了灵活性(不同项目的评分公式不同,不用硬编码),又保证了结果的精确性和可复现性。

先说说整体流程

Code Agent 在流水线里是两个连续的节点:CodeGenerationNodeCodeExecutionNode,是 build_scoring_flow 里定义的。

第一步,CodeGenerationNode 调 LLM 的 code_generator 方法,传入两个东西:评分要点(scoring_point)和从标书里提取的财务数据(extracted_data)。LLM 的输出是一段 Python 代码,实现了评分逻辑。

第二步,CodeExecutionNodecode_executor 方法,把上一步生成的 Python 代码写到临时文件,用 subprocess.run 在新进程里执行,拿到执行结果。

第三步,ScoringExplanationNode 把代码和结果再送一次 LLM,生成自然语言的评分过程解释——就是专家在页面上看到的"AI 为什么给这个分数"。

保证了哪些安全措施

代码执行的安全是我们最在意的事情之一。因为 LLM 生成的代码可能包含恶意操作,也可能因为 bug 导致死循环或资源耗尽。我们做了这么几层防护:

第一层:超时保护。 这是最基本的防线。AGENT_CODE_EXECUTION_TIMEOUT 配置了 30 秒超时,代码执行超过 30 秒会被 subprocess.TimeoutExpired 中断。防止 LLM 生成死循环或长时间运行的代码。

timeout = settings.AGENT_CODE_EXECUTION_TIMEOUT # 30s
result = subprocess.run(
['python3', temp_file],
check=False,
capture_output=True,
text=True,
timeout=timeout,
)

怎么保证安全的

说实话我们这个 Code Agent 的安全不是靠某一个机制搞定的,是好几层叠在一起,每层拦一类问题。

第一层:进程隔离——不是 eval,但拦不住文件操作

代码不在当前进程跑,而是写临时文件在子进程执行。这层隔离主要防止的是:代码崩了不会拖垮主进程、死循环可以被 timeout 杀掉。但说实话,subprocess 和主进程共享文件系统,代码里写 os.remove() 确实能删主进程的文件。所以进程隔离本身不够,得靠后面几层补。

with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
f.write(code)
temp_file = f.name
result = subprocess.run(['python3', temp_file], capture_output=True, timeout=30)
os.unlink(temp_file)

第二层:专用虚拟环境——没装库,想调也调不了

代码走的是 /workspace/.venv/bin/python,这个虚拟环境是专门给代码执行准备的,只装了评分计算需要的东西——jsonmath,没了。requests 没有,subprocess 没有,os 模块虽然 Python 自带但相关的危险函数也没法用,因为没有权限访问外部资源。

LLM 生成的代码如果 import requests 或者 import socket,直接 ModuleNotFoundError,跑都跑不起来。从依赖层面就做了限制。

try:
result = subprocess.run(['/workspace/.venv/bin/python', temp_file], ...)
except FileNotFoundError:
# 降级到系统 python3
result = subprocess.run(['python3', temp_file], ...)

第三层:30 秒超时——死循环不存在的

AGENT_CODE_EXECUTION_TIMEOUT = 30,代码跑到 30 秒还没出结果,subprocess.TimeoutExpired 直接杀掉进程。LLM 生成的代码再烂也不会把系统拖死。

第四层:输出格式校验——不是 JSON 就不认

代码执行完 stdout 必须是合法的 JSON。如果 LLM 生成的代码跑了什么乱七八糟的东西、输出了一堆垃圾,json.loads 那步就失败了,结果不会被采纳。这其实也是安全的一环——输出格式卡死了,代码想搞事情也没办法把结果传出来。

第五层:Prompt 约束——从一开始就告诉模型别乱来

代码生成的 system prompt 写得很清楚:你只需要写评分计算逻辑,不要写文件、不要调网络、不要做系统调用。而且传入的数据是提前提取好的结构化的 JSON,代码只负责做数值运算和条件判断,不需要读任何外部数据。

scope 被限制在"输入几个数字 → 算一个分数"这个极小的范围内,出问题的可能性天然就低。

第三层:输出严格校验。 代码执行完成后,stdout 必须是一个有效的 JSON。如果输出不是 JSON 格式,直接判为失败。这个设计是为了防止代码输出异常内容被下游消费。

if result.returncode == 0:
try:
output_data = json.loads(result.stdout.strip())
except json.JSONDecodeError:
raise Exception('代码执行失败:输出不是有效的JSON格式')

第四层:只写 Python 评分逻辑,不做系统操作。 虽然我们没有用严格的限制措施(比如 seccomp 或者白名单库),但从 Prompt 层面约束了 LLM 生成的代码类型。code_generator 的 system prompt 明确要求 LLM 只生成评分计算逻辑,不涉及文件操作、网络请求或系统调用。代码里只处理数值计算和条件判断,scope 非常有限,天然就不太可能造成破坏。

第五层:Temporal Activity 的重试和超时。 Code Execution 本身是一个 Temporal Activity,配了超时和重试策略。如果代码执行因为某些异常原因崩溃了,Activity 会自动重试。如果重试次数用尽还是失败,Workflow 会把结果标记为失败,走人工兜底流程,不会因为 Code Agent 卡死导致整个评审流程中断。

关于权限的考量

说句实话,我们 Code Agent 的权限模型目前比较简单,没有做到真正的沙箱级别隔离。几个现实情况:

第一,目前生成的代码只涉及数值计算——根据公式算分,不读文件、不连网络、不做系统调用。代码的内容决定了它的破坏力非常有限。我们评估过风险,在当前的应用场景下,进程隔离 + 超时保护已经足够。

第二,如果有更复杂的代码执行需求——比如需要读取投标文件里的特定数据再做计算——我们会考虑用 Docker 容器或者 gVisor 做真正的沙箱执行。目前没做,因为用不到。

第三,执行结果必须输出符合预期结构的 JSON,这个严格校验本身也是一种安全措施——即使代码执行了意料之外的操作,只要输出格式不对,结果不会被采纳。

总结一下

Code Agent 的核心思路是"生成代码 → 沙箱执行 → 拿结果"的三步流水线,解决了 LLM 不擅长精确计算的短板。安全方面做了四层防护:30 秒超时中断、subprocess 进程隔离、JSON 输出严格校验、Prompt 层面约束代码 scope。目前这种设计在数值评分场景下够用,但如果未来代码执行的场景扩展到文件操作或网络请求,需要升级到容器级别的沙箱隔离。

问题:你们的 Agentic RAG 怎么设计的,主要体现在业务的哪些地方

Agentic RAG 和普通 RAG 最大的区别在于:普通 RAG 是"检索一次 → 生成回答"的固定流水线,像传送带一样,检索结果好不好都硬着头皮生成。Agentic RAG 则把 LLM 当 Agent 用——它自己判断检索结果够不够、要不要换个角度再查一次、需不需要拆成多个子问题分别检索。

我们的 Agentic RAG 不是为 RAG 而 RAG 的,是业务倒逼出来的。我拿两个业务场景具体说一下。

场景一:业绩评审的 Split → Rethink 模式

这是 Agentic RAG 体现最充分的地方。业绩评审的业务逻辑是:投标人声称自己有若干项业绩,Agent 需要逐项判断每项业绩是否满足招标要求。

如果用普通 RAG 的做法,就是把所有业绩描述和招标条款拼在一起,一次丢给 LLM:"请判断这些业绩是否满足要求"。这样做的问题是——好几项业绩混在一起,LLM 可能漏判某项,或者 A 业绩的条款引用到 B 业绩上,张冠李戴。

我们的 Agentic RAG 是这样做的:

第一轮:Split(拆分判断)。 Agent 先把投标人的多个业绩拆解开,然后逐个业绩独立做检索和判断。每判断一项业绩,Agent 自己决定去检索哪些招标条款——不是我们预先写死的检索 query,而是 Agent 根据当前业绩内容动态生成的。判断完一项,记下结果和引用证据,再判断下一项。

这一轮里"Agentic"体现在哪?体现在检索是动态的、按需触发的。Agent 看到一条"XX 地铁 3 号线施工合同",它自己决定去检索招标文件里"类似项目业绩"相关的条款,不是我们事先配好的。

第二轮:Rethink(反思复核)。 所有业绩判断完后,Agent 把第一轮的结果汇总,自己做一次反思:"有没有漏判的?有没有误判的?前后判断逻辑一致吗?"如果发现问题,Agent 会主动发起补充检索——比如发现某个判断的引用依据不够充分,就重新检索更精确的条款。

这轮"Agentic"体现在自我评估和补充检索的能力。普通 RAG 没有"我觉得刚才可能查少了,再查一次看看"这种机制。

第三层兜底:Full-text 降级。 如果 RAG 检索到的内容不足以支撑判断(比如招标要求写得很模糊,向量检索匹配不到精确条款),Agent 会自己判断"检索结果不够",然后主动降级到全文检索模式——直接在整份招标文件里做关键词匹配。

这个降级决策是 Agent 做的,不是代码写死的"if 相似度 < 阈值 then 降级"。

场景二:财务评审的 Code Agent(虽不是 RAG 但体现了 Agentic 思想)

财务评审里的 Code Agent 也体现了 Agentic 的"自主决策"思路。Agent 从标书里提取了财务数据后,不直接输出分数——它先自己写一段 Python 代码来实现评分公式,然后在沙箱里执行,拿到精确结果,最后再基于执行结果生成评分解释。

这里 Agentic 体现在:Agent 不是按照固定的"if-else"规则链执行,而是根据当前评分因素的公式特征自动生成计算逻辑。不同项目的评分公式千差万别——有的是线性打分、有的是阶梯扣分、有的是基准价浮动——Agent 自己判断用哪种逻辑并生成对应代码。

为什么普通 RAG 搞不定这两个场景

回过头来看,普通 RAG 在这两个场景上失效的原因是一样的:

第一个问题是**"一次检索覆盖不全"**。业绩评审涉及多项业绩、多个招标条款,一次检索不可能把所有相关信息都找全。Agentic RAG 通过多轮、动态的检索来解决——第一轮没找到就换角度再查。

第二个问题是**"没有自我纠错"**。普通 RAG 检索到什么就用什么生成答案,不会判断"检索结果够不够好"。Agentic RAG 通过反思步骤来弥补——Agent 自己评估答案质量,不满意的重新检索。

第三个问题是**"没法处理需要拆解的任务"**。把多项业绩混在一起判断,和拆开逐项判断再汇总,效果差很远。Agentic RAG 的 Split 步骤解决了这个问题。

整个链路里的 RAG 组件

即便有了 Agentic 的决策和反思能力,底层的 RAG 组件也不能太弱。我们的检索链路本身也是层层优化的:

Query 进来先做重写——Agent 当前的判断目标(比如"判断 XX 项目是否属于类似项目业绩")会被重写成多个检索友好的 query,提高命中率。检索走多路——向量找语义、BM25 找专有名词,结果用 RRF 融合。融合后的 top-15 再过一遍 Cross-Encoder Reranker 精排,取 top-5 送 Agent。

这些组件是 Agentic RAG 的"手脚"——Agent 负责决策(查什么、够不够、要不要再查),RAG 组件负责执行(检索、排序、精排)。

总结

Agentic RAG 在我们项目里不是一个独立的功能模块,而是渗透在好几个业务场景里的设计模式。业绩评审的 Split → Rethink 是最典型的体现——Agent 自己拆任务、自己检索、自己反思、自己决定要不要补充检索。底层 RAG 组件(Query Rewrite、多路召回、Reranker)提供了支撑能力。核心区别就一句话:普通 RAG 是"检一次就答",Agentic RAG 是"不够就再检,直到满意为止"。

问题:LangGraph 怎么用的,为什么不用 LangChain

LangGraph 和 LangChain 其实不是二选一的关系,我们在项目里两个都用了,只是分工不同。

LangChain 用在哪

LangChain 在 kv-ragent 这个库里主要当"工具包"用的。具体来说用了三样东西:

一是 ChatOpenAI,它是一个 LLM 调用封装,帮我们省了自己写请求重试、流式解析这些 boilerplate。二是 langchain_core.messages 里的 HumanMessageAIMessage,用来标准化消息格式。三是作为 LangGraph 的依赖——LangGraph 本身需要 langchain 的 message 类型来定义图状态。

我们没有用 LangChain 的 chain、LCEL 表达式、document loader、text splitter 这些东西。因为我们的场景比较固定,不需要抽象那么高的层次。LangChain 的 chain 抽象对简单"检索→生成"还行,但一遇到条件分支、循环、多轮交互就不好用了,强行用 chain 反而代码又绕又难调试。

LangGraph 用在哪

LangGraph 用在 kv-ragent 的 Agentic RAG 工作流编排上。核心文件是 agentic.py,实现了一个多轮"检索→回答→反思→再检索"的循环流程。

构建图的核心代码大概这样:

workflow = StateGraph(AgenticState)

workflow.add_node("retrieve", self.retrieve_node)
workflow.add_node("answer", self.answer_node)
workflow.add_node("reflection_and_question", self.reflection_and_question_node)
workflow.add_node("synthesis", self.synthesis_node)

workflow.add_edge(START, "retrieve")
workflow.add_edge("retrieve", "answer")
workflow.add_edge("answer", "reflection_and_question")

# 条件路由:反思后决定继续检索还是综合
workflow.add_conditional_edges(
"reflection_and_question",
self.route_after_reflection_and_question,
{"retrieve": "retrieve", "synthesis": "synthesis"}
)

workflow.add_edge("synthesis", END)
return workflow.compile()

四个节点:retrieveanswerreflection_and_questionsynthesis。关键在于条件路由——反思节点根据当前答案的完整度决定是回到 retrieve 继续检索,还是进入 synthesis 做最终综合。这个"自己判断够不够,不够就再查"的循环,用传统的 chain 很难表达。

AgenticState 是一个自定义状态,除了 LangGraph 内置的 messages 字段,还带了 current_roundquestion_historyaccumulated_referenced_docs 等业务相关字段,用来在工作流节点之间传递数据。

另一个 retrieve.py 里也用了 LangGraph,但走的是固定流程(retrieve → generate_answer),没有条件分支。这个其实用 chain 也能实现,但为了和 agentic 模式保持统一的技术栈,也用了 LangGraph。

为什么不用 LangChain 的 chain

说到底是控制力和可调试性的权衡。简单场景(检一次就答)用 LangGraph 和用 chain 区别不大,但一旦涉及多轮循环、条件路由,chain 的声明式 DSL 就跟不上——你很难在 chain 里表达"如果答案不完整就重新检索换个角度再查"这种逻辑。

LangGraph 把工作流定义成节点 + 边的图,每个节点是一个独立的 async 函数,可以单独 debug、单独加日志、单独加重试。对比 LangChain chain 那种把逻辑写在 LCEL 表达式里的一行链式调用,调试体验好很多——哪个节点执行失败,日志一看就知道。

另外我们的状态是 typedict 结构,类型明确,不容易出现 chain 里那种隐式的 context 传递问题。

有啥缺点

LangGraph 也不是完美的。对简单场景来说它有点重——为了一个"检索→回答"的固定流程,也要定义 State、Node、Graph 一套东西。而且 LangGraph 的图结构在复杂场景下维护成本会上升——节点多了之后,边的连接关系肉眼很难追踪。目前我们控制在 4-5 个节点,还算可控。

另外 LangGraph 版本迭代比较快,v0.2v0.3 改了一些 API(比如 add_conditional_edges 的签名变了),升级的时候需要适配。所以我们 lock 了版本 langgraph>=0.6.2

总结

LangGraph 做的是"Agent 内部的执行图编排"——控制多轮检索-回答-反思循环的流转。Temporal 做的是"项目级长流程编排"——控制文档解析、AI 评审、专家复核这些宏观阶段的流转。两层编排各管各的,LangGraph 不取代 Temporal,Temporal 也不取代 LangGraph。LangChain 在这中间只是当工具库用,它的 chain 层我们没用。如果未来要支持更复杂的图逻辑(并行节点、子图嵌套),LangGraph 的图模型也留了扩展空间。

问题:为什么选 Temporal 而不是 Celery 或 Redis 队列?在长流程中怎么解决确定性问题?Signal 人机协作怎么实现的?

这个问题我拆成两块说:为什么选 Temporal,以及 Signal 人机协作具体怎么做。

为什么选 Temporal 而不是 Celery

最直接的原因:Celery 本质是个无状态的任务队列,不适合编排长流程。

招投标评审一个流程可能跑几个小时甚至跨天,中间涉及文档解析、多个 AI Agent 并行评审、专家复核等待。Celery 的任务一旦发出,你就没法知道它跑到哪一步了。失败了它重试也不是不行,但重试是从头跑还是从失败点继续?Celery 没有"断点续跑"这个概念——要么整个重试,要么放弃。

Temporal 的核心区别在于:Workflow 的执行状态是持久化的。Worker 宕机了,Temporal Server 知道 Workflow 跑到了哪个 Activity,重启后自动从断点继续执行,不会重做已经完成的工作。这一点对长流程来说是根本性的差异。

另外几个关键差异:

  • Signal 机制:Celery 没有"等一个人"的能力。专家复核需要等专家响应,Celery 做不到 workflow 暂停等待外部信号再恢复。Temporal 的 Signal 是原生支持的。
  • 确定性保证:Temporal 要求 Workflow 代码必须确定性——不能调 datetime.now()、不能生成随机数、不能直接调外部 API。所有非确定性操作都放到 Activity 里。这个约束确保了 Workflow 重放时执行路径完全一致。
  • 重试策略:Temporal 的 Activity 支持指数退避、最大重试次数、不可重试错误类型。Celery 也有重试,但精细化程度差一截。
  • 可观测性:Temporal 有 Web UI,可以看到每个 Workflow 的执行状态、每个 Activity 的耗时和重试次数。排查问题时直接看 UI,不需要翻日志。

Redis 队列就更不用说了——它就是个消息通道,连重试和状态管理都没有,只适合做简单的异步任务解耦。

Signal 人机协作的具体实现

评审要点生成后,Workflow 会进入一个等待状态。代码层面大概是这个模式:

Workflow:
1. 执行文档解析 Activity
2. 执行评审要点生成 Activity
3. 进入 WAITING_CONFIRMATION 状态
→ 发送通知给专家:"请确认评审要点"
→ 调用 Workflow.wait_for_signal() 暂停在这里
4. 专家在页面确认/修改后,前端调 API 发送 Signal
5. Workflow 收到 Signal,拿到的 Signal 载荷包含专家的确认结果
6. 继续执行后续 Activity(KV 提取、AI 评审...)

关键设计是 wait_for_signal。这个 Signal 的定义本身不复杂,就是定义了一个 receiver 函数:

@workflow.defn
class TenderEvaluationWorkflow:
@workflow.run
async def run(self, params: Params) -> Result:
# ... 前面的 Activity
expert_confirmed = await workflow.wait_for_signal(self.handle_expert_confirmation)
# ... 后续 Activity

@workflow.signal
def handle_expert_confirmation(self, confirmation: ExpertConfirmation) -> None:
self._confirmation = confirmation

专家 24 小时不响应怎么办

这个问题很实际。我们不会让 Workflow 无限期等着。设计是三层超时:

第一层,Workflow 本身配置了 execution_timeout=24h,超过 24 小时整个评审流程自动终止。

第二层,Signal 等待期间启动一个超时 Timer。比如设置等待专家确认的超时是 4 小时。4 小时内没收到 Signal,Timer 触发,Workflow 自动走默认路径——按 AI 自动生成的要点继续执行,但标记"专家未确认"。

# 启动一个 Timer,4 小时后触发
timeout_future = workflow.start_timer(4 * 3600)

# 同时等待 Signal 和 Timer
result = await workflow.wait_for_signal(
self.handle_expert_confirmation,
timeout=timeout_future # Timer 到了也算"收到了",走默认逻辑
)

第三层,如果超时后用默认逻辑继续执行,前端会显示"专家未在时限内确认,系统已自动采用 AI 生成的要点"。如果专家后面登录系统,可以看到还有入口修改——不是超时了就不能改了,只是 Workflow 不等了。

所以状态维持的核心就是 Temporal 的持久化 Workflow 状态 + Timer 超时机制。Workflow 等不等、等多久都是代码控制的,不会因为专家忙就卡死整个流程。

问题:沙箱安全方面,除了 subprocess,有没有考虑 WASM 或 Docker?json-repair 和重试怎么配合的?

两个问题分开说。

沙箱隔离方案选型

我们用了 subprocess + 专用虚拟环境,这层隔离在数值评分场景下够用,但不是终极方案。

考虑过的方案按隔离强度排:

最轻量的是 subprocess 进程隔离。好处是零额外基础设施,写几行代码就能用。坏处是共享文件系统,代码里写 os.remove() 确实能删文件。我们靠专用虚拟环境没装危险库 + Prompt 约束 scope 来补这个短板。

中间层是 WASM 沙箱(比如 Wasmtime 跑 Python 代码)。隔离性比 subprocess 强很多,没有文件系统访问、没有网络调用。我们没有用的原因是:Python 在 WASM 上跑有限制——部分标准库不支持、性能有损耗、调试不方便。而且我们的代码是 LLM 生成的,质量控制本来就低,再加一层 WASM 的兼容性问题,排查起来很头疼。

再往上走是 Docker 容器执行。隔离性最好,资源可以严格限制(CPU、内存、磁盘)。我们没有用的原因主要是延迟和资源开销——每次代码执行都启动一个容器,冷启动要几百毫秒到几秒,而且容器管理(镜像维护、资源回收、并发控制)需要额外的基础设施投入。如果未来代码执行的场景扩展到需要文件读取或网络请求,Docker 是必须上的。

还有一条路是 Pyodide(Python 在浏览器 WASM 里跑),但我们后端服务没法用浏览器方案。

目前 subprocess + 专用虚拟环境对数值评分来说是性价比最高的方案。代码只做"读几个数值 → 算个分数 → 输出 JSON",scope 极小,风险可控。

json-repair 和重试的配合

这个配合其实挺简单的,不复杂:

第一步,LLM 生成代码后,我们用 subprocess.run 执行。如果返回码非零(代码报错了),直接进入重试流程——把错误信息(stderr)拼到新 Prompt 里,让 LLM 重新生成一份修复后的代码。重试最多 3 次。

for attempt in range(3):
code = llm.generate_code(prompt)
result = subprocess.run(['python3', temp_file], capture_output=True)
if result.returncode == 0:
# 执行成功,解析输出
return json.loads(result.stdout)
else:
# 执行失败,把错误信息喂给 LLM 让它修
prompt += f"\n\n上次生成的代码执行报错:{result.stderr}\n请修复后重新生成。"

第二步,如果代码执行成功了(returncode == 0),但 stdout 不是合法 JSON——这种情况比较少见,因为生成的代码通常会 print(json.dumps(...)),但也可能输出格式不对——会尝试用 json_repair 库做一次修复。json_repair 能处理常见的不规范 JSON,比如末尾多了逗号、单引号代替双引号、字段名没加引号这些。

如果 json_repair 也修不好,就判失败,走重试——错误信息换成"输出不是合法 JSON,请确保输出符合格式要求"。

第三步,重试耗尽还是失败,就把这个评分项标记为"部分失败"(PARTIAL_FAILED),不影响其他评分项的正常评审。Workflow 继续执行,最终报告里标注"该项 AI 评审未完成,需人工补充"。

所以 json-repair 不是重试的替代,而是重试之前的一道防线——能修好的就不浪费一次 LLM 调用。

问题:多 Agent 之间是如何通信的?中心化编排还是去中心化协作?

中心化编排(Orchestration),不是去中心化协作(Choreography)。

这个选择不是拍脑袋定的,而是由业务特性决定的。招投标评审是一个确定性流程——先解析文档、再做 AI 评审、再专家复核、最后出报告,顺序和依赖关系是固定的。这种场景天然适合中心化编排,不适合去中心化协作。

具体来说,我们的编排分两层:

第一层:Temporal Workflow 做宏观编排

这是最上层的总指挥。Workflow 定义了评审的主流程——从文档解析开始,到评审要点生成、KV 提取、AI 评审(财务、业绩、技术、围串标预警并行跑)、专家复核、报告生成。每个阶段是一个 Activity 或一组并行的 Activity,Workflow 负责控制执行顺序、传递中间结果、处理失败重试。

Workflow 在这里的角色就是"总导演"——它不干活,但它知道谁先谁后、谁依赖谁。

第二层:PocketFlow 状态机做 Agent 内部编排

宏观编排到"AI 评审"这个 Activity 后,内部还有更细的编排。比如财务评审 Agent 内部是 6 步推理链:需求提取 → 财报抽取 → 合规审查 → 数据提取 → 代码生成 → 评分解释。

这一步用的是 PocketFlow——一个轻量级的状态机框架。每个步骤是一个 Node,节点之间通过条件边连接(比如合规审查不通过就跳过评分步骤)。PocketFlow 的好处是每个 Node 是独立的函数,可以单独测试、单独加日志。

Agent 之间怎么通信?

通信方式有三种,按场景选:

方式一:Temporal Workflow 传递数据(最主要的通信方式)

Agent 之间不直接调用,而是通过 Temporal 的 Activity 返回值传递数据。前一个 Activity 的输出作为后一个 Activity 的输入。Workflow 代码里这样写:

# Workflow 代码(伪代码)
docs = await workflow.execute_activity(parse_documents, params)
key_points = await workflow.execute_activity(generate_key_points, docs)
financial_results = await workflow.execute_activity(finance_evaluation, key_points, task_queue="agent-finance")
performance_results = await workflow.execute_activity(performance_evaluation, key_points, task_queue="agent-performance")
report = await workflow.execute_activity(generate_report, [financial_results, performance_results])

财务评审和业绩评审没有依赖关系,Workflow 用 fan-out 并行执行它们。

方式二:数据库共享状态

对于数据量大的中间结果,不走 Activity 参数传递,而是直接写数据库。比如文档解析 Activity 把结构化文档写到 BidFile 表,KV 提取 Activity 去读 BidFile,把提取结果写到 PointKVAnalysis 表,评审 Agent 再去读 PointKVAnalysis

这种"写表→读表"的方式看起来比直接传参多了一步,但它有好处:每个 Activity 是幂等的,重试时不会丢失中间数据;而且调试时可以直接查数据库看看某一步输出了什么,不用翻日志。

方式三:Dify Workflow 内部多步骤

技术评审走的是 Dify Workflow。Dify 内部可以编排多个 LLM 节点串行执行,节点之间通过变量传递数据。对上层 Temporal Workflow 来说,整个 Dify Workflow 就是一个黑盒 Activity——只管发请求、等结果,不关心内部怎么跑的。

为什么不去中心化协作?

去中心化模式(Agent 之间通过消息队列直接通信、各自订阅感兴趣的事件)在灵活性上有优势,但不适合我们的场景,原因有三:

第一,流程的可追溯性。招投标评审有合规要求——每一步是谁做的、依据是什么、结果是什么,都必须能追溯。中心化编排下,查 Workflow 的执行历史就能还原整个评审过程。去中心化下,事件分散在各个 Agent 的日志里,追溯很麻烦。

第二,失败恢复。中心化编排下,Workflow 知道整个流程的状态。哪个 Activity 失败了、重试了几次、现在在等什么,一目了然。去中心化下,Agent 挂了恢复起来要复杂的多。

第三,确定性。招投标评审的流程是固定的,不是开放式的探索任务。中心化编排最自然。

总结一下:两层编排,Temporal 管宏观、PocketFlow 管微观,都是中心化的。Agent 通信以 Workflow 传参和数据库共享两种方式为主。不采用去中心化协作模式。

问题:动静分离知识库在 chunk 和检索策略上有什么不同?MinerU+VLM 怎么处理表格和复杂排版?

两个问题有关联,我一块说。

动静分离在 Chunk 策略上的差异

先说静态知识(评审标准、法规)。这类文档的特点是结构清晰——评审标准一条一条的,法规一章一节的。所以我们按文档结构切(Markdown 标题层级做分割线),每一条评审标准或法规条款作为一个独立 chunk。这种切法粒度不均,但语义完整——一个 chunk 就是一个完整的判断依据。chunk_size 不固定,取决于条款长度,从几十 token 到上千 token 都有。

静态知识的 chunk 做一次就持久化了,不会频繁更新。embedding 也是离线批量算的,存储在向量库里不动。

动态知识(招标/投标文件)就不一样了。这类文档几百页起,结构复杂——有正文、表格、附件、资质证明混在一起。而且每个项目的文件都不一样,不能提前切好。

动态知识的 chunk 策略是递归字符分割,chunk_size 固定 512 token,overlap 64。不按结构切的原因是:标书的章节标题意义不大——不同供应商写的标书结构五花八门,"项目管理"这节有的写 10 页有的写 2 页。按固定大小切虽然粗暴,但保证了每个 chunk 的语义密度相对均匀。

但动态知识有一条预处理流水线:先经过 KV Agent 提取结构化数据,把关键信息(资质、金额、工期)提成键值对,这些 KV 本身也作为"超级 chunk"参与检索。所以动态知识实际上是两类 chunk 并存——文本 chunk(512 token)用于找上下文,KV chunk(一句话一个值)用于精确匹配。

检索策略的差异

静态知识的检索比较简单。因为评审标准数量有限(几百条),检索范围小,top-5 基本覆盖。不需要多路召回,纯向量检索就够了。

动态知识的检索复杂很多。检索范围大(一个项目可能几千个 chunk),而且表述风格多样。所以走完整链路:Query 重写(评分标准拆多个角度)→ 多路召回(向量 + BM25)→ RRF 融合 → Cross-Encoder 精排取 top-5。

MinerU+VLM 处理表格和排版

这个问题其实前面的 Q&A 说过思路,我补充一下表格和排版的具体处理。

MinerU 把整份 PDF 转成结构化 Markdown,其中表格会被识别为表格区域,输出 Markdown 表格格式。对于标准表格(有线框、文字清晰),这一步就够了。

但招投标文件里的表格经常很复杂——有的是无框表格(只有文字对齐)、有的是合并单元格、有的是表格里嵌图片。MinerU 对这种复杂表格的输出经常是乱的:行列对不上、合并单元格解析成空行、表格里的图片被忽略。

我们的处理是:MinerU 在处理表格时,会同时输出一个置信度评分。如果某个表格的置信度低于阈值(比如 0.6),就把表格区域截图,交给 Qwen-VL 做专项识别。

VLM 识别表格时用了一个专门的 Prompt:

请识别图中的表格,输出为 Markdown 表格格式。注意:
1. 保留合并单元格的层级关系
2. 空单元格输出为空
3. 表格中的图片用 [图片:描述] 占位

VLM 的输出比 MinerU 直接解析准确很多,特别是对无框表格和合并单元格。代价是每个表格多了一次 VLM 调用,但好在复杂表格占比不高(大约 15-20%),整体成本可接受。

合并后的语义完整性怎么保证

这个问题问得好。分开解析(MinerU 做文本、VLM 做表格)后,怎么保证拼回来的文档语义是连贯的?

我们的做法是:以 MinerU 的 Markdown 结构为骨架,VLM 的输出只替换对应的表格节点。流程是:

  1. MinerU 输出的 Markdown 里,每个表格块有唯一 ID(<table id="tab_003">...
  2. VLM 识别出的表格也有同样的 ID 标记
  3. 合并时,找到 MinerU 输出中的 <table id="tab_003"> 区块,整体替换为 VLM 的 Markdown 表格输出
  4. 替换是"节点级"的——不碰表格前后的文本段落,不影响文档的段落顺序和章节结构

这样合并后,文档的正文段落、标题层级、表格位置都保持原样,只是表格内容被优化了。下游 Agent 读到的是一篇结构完整的 Markdown 文档,不会感知到"这里经过了两个模型的拼接"。

问题:Langfuse 有没有用到 Evaluation 功能?自动化评测集怎么构建?反思机制具体怎么设计的?

Langfuse Evaluation 的使用情况

用了,但没有用 Langfuse 内置的 Evaluation 功能来做自动化打分。我们的用法比较务实——把 Langfuse 当数据平台用,Evaluation 的逻辑自己写。

具体做法是:每一条 LLM 调用的输入、输出、耗时、Token 数都 Trace 到 Langfuse。然后在 Langfuse 里按 project_id + prompt_version 筛选,批量导出到本地。用 Python 脚本跑自定义的评测逻辑——比如检查输出 JSON 格式是否合法、关键字段是否缺失、推荐分是否在合理范围内。结果回写到 Langfuse 的 Evaluation 面板上。

没有用 Langfuse 内置 Evaluation 的原因是我们的评测维度比较业务化——不是简单的"答案对错",而是"评分理由是否包含了关键词"、"引用的 chunks 是否真实存在"这些。Langfuse 的 Evaluation 适合通用场景,业务定制灵活性不够。

自动化评测集的构建

评测集的核心来源是线上 bad case 回流。每次专家修改了 AI 评分,这个修改本身就是一个天然的标注样本。我们有一条回流 pipeline:

专家修改 AI 评分

自动生成一条结构化记录:评分因素 + AI 评分 + 专家评分 + 修改理由

这条记录进入"待审核"测试集池

人工抽检确认(每周一次,抽检 20%)

确认通过 → 加入正式评测集

评测集没有做很大,大概 300 条左右。因为招投标评审的测试 case 构建成本高——每条需要标注:应该检索到的 chunks、正确的评分值、评分理由应该包含的关键信息。人工标注一条大概 10-15 分钟。300 条覆盖了常见的评审场景(正常通过、数据缺失、格式异常、边界条件),性价比最高。

跑评测的时候,用这 300 条 case 对新旧两个版本的 Agent 同时跑,对比三个维度的指标:

  • 评分偏差:AI 评分和标准分的平均绝对误差(MAE)
  • 格式合规率:输出是否合法 JSON,字段是否完整
  • 引用准确率:引用的 chunks 是否真的包含了声称的信息

三个指标都通过才能上线。其中引用准确率是最容易翻车的——加了新的 RAG 优化后,检索到的 chunks 变了,Agent 可能引用了一个不包含实际数据的 chunk,这是最常见的 regression。

反思机制的具体设计

这个机制在 Agentic RAG 的流程里,核心是 reflection_and_question_node。我拆开讲一下实现细节。

Agent 每轮执行完 retrieve → answer 后,会进入反思环节。反思不是让 LLM 随便说说"你觉得答案够不够好",而是给它一个结构化的评估框架,要求按优先级逐层判断:

第一优先级(P0):执行前提是否缺失
答案里有没有引用外部条件?("详见 XX 标准"、"按 XX 规定")
如果有,这些条件在检索结果里有没有?没有 → 判定为不完整

第二优先级(P1):规则定义是否模糊
答案里的判断逻辑有没有歧义?
如果有 → 判定为不完整,需要补充检索

这个优先级设计很关键。实践中发现,Agent 最常犯的错误不是"答错了",而是"答得模糊"。比如 Agent 说"该业绩不符合要求,因为合同金额不足 500 万"——但 500 万这个阈值是招标文件里写的,Agent 凭"记忆"写出来的,当时检索到的 chunks 里其实没找到这个数字。P0 检查就是抓这种"引用了一个外部条件但没提供来源"的问题。

如果反思判定为不完整,Agent 会生成一个新的检索 query,目标是精准获取缺失的信息。新 query 的生成也受约束——不能和历史问题重复(Jaccard 相似度阈值 0.9 判断),必须针对最高优先级的缺失信息。

循环会持续,直到满足以下任一条件终止:

  1. 反思判定答案完整
  2. 达到最大轮次(默认 4 轮)
  3. 生成的新问题和历史问题高度重复(说明问不出新东西了)
  4. 连续两轮答案内容无变化(陷入死循环保护)

终止后进入 synthesis 节点,把多轮答案整合为最终结果,同时去重合并所有轮次引用的 chunks。

问题:画出 Temporal 工作流图和多 Agent 协同图

Temporal 评审主工作流

┌─────────────────────────────────────────┐
│ TenderEvaluationWorkflow │
│ execution_timeout=24h │
└─────────────────────────────────────────┘


┌─────────────────────────┐
│ Activity: parse_docs │
│ Knowdoc 文档解析 │
│ retry: 3次, 指数退避 │
└──────────┬──────────────┘
│ 结构化 Markdown

┌─────────────────────────┐
│ Activity: gen_key_points│
│ LLM 提取评审要点 │
└──────────┬──────────────┘
│ 评审要点

┌─────────────────────────────┐
│ Signal: wait_for_expert │
│ ⏳ 等待专家确认要点 │
│ Timer: 4h 超时 │
│ 超时 → 自动采用 AI 生成 │
└──────────┬──────────────────┘
│ 专家确认/修改

┌─────────────────────────────┐
│ Activity: kv_extraction │
│ KV Agent 结构化提取 │
└──────────┬──────────────────┘
│ 结构化 KV 数据

┌────────────────────┴────────────────────┐
│ Fan-Out 并行 │
│ │
┌──────────▼──────────┐ ┌───────────────▼─────────┐
│ Activity: finance │ │ Activity: performance │
│ 财务评审 Agent │ │ 业绩评审 Agent │
│ task_queue: │ │ task_queue: │
│ agent-finance │ │ agent-performance │
│ retry: 4次, 30s 超时 │ │ retry: 4次, 30s 超时 │
└──────────┬───────────┘ └───────────────┬─────────┘
│ │
┌──────────▼──────────┐ ┌───────────────▼─────────┐
│ PocketFlow 6步链: │ │ Agentic RAG: │
│ 需求提取→财报抽取→ │ │ Split→Rethink 多轮判断 │
│ 合规审查→数据提取→ │ │ Cache + Fulltext Fallback│
│ 代码生成→评分解释 │ │ │
└──────────┬───────────┘ └───────────────┬─────────┘
│ │
└────────────────┬────────────────────────┘
│ 合并结果

┌────────────────────────────────────────┐
│ Activity: collusion_warning │
│ 围串标预警(项目级 Agent) │
│ 判定无效投标 → 标记跳过后续评审 │
└───────────────────┬────────────────────┘


┌────────────────────────────────────────┐
│ Activity: technical_review │
│ 技术评审 → Dify Workflow │
└───────────────────┬────────────────────┘


┌────────────────────────────────────────┐
│ Activity: price_calculation │
│ Code Agent 沙箱执行 │
│ timeout=30s, subprocess 隔离 │
└───────────────────┬────────────────────┘


┌────────────────────────────────────────┐
│ Signal: wait_for_expert_review │
│ ⏳ 等待专家复核所有评分 │
│ Timer: 24h 超时 │
└───────────────────┬────────────────────┘
│ 专家确认

┌────────────────────────────────────────┐
│ Activity: generate_report │
│ 报告生成 → LLM 摘录 + Jinja2 → DOCX │
│ ReportContextBuilder + Resolvers │
└───────────────────┬────────────────────┘
│ DOCX 上传 OBS

┌────────────────────────────────────────┐
│ Workflow 完成 │
│ project.status = 已完成 │
└────────────────────────────────────────┘

多 Agent 协同架构图

┌─────────────────────────────────────────────────────────────────────────┐
│ Temporal Workflow (总导演) │
│ 编排执行顺序、并行调度、Signal 等待、失败重试 │
└──────┬─────────────────────────────────────────────────────┬───────────┘
│ │
│ Activity 调用 │ 数据库共享
▼ ▼
┌───────────────────────────────────────────────────────────────────────────┐
│ Agent Layer │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────────────────┐ │
│ │ 财务评审 Agent │ │ 业绩评审 Agent │ │ 围串标预警 Agent │ │
│ │ (Master Agent) │ │ (Master Agent) │ │ (项目级 Agent) │ │
│ └────┬────────────┘ └────┬────────────┘ └──────────┬───────────────┘ │
│ │ │ │ │
│ ▼ PocketFlow ▼ Agentic RAG ▼ rules-driven │
│ ┌────────────┐ ┌────────────┐ ┌──────────────┐ │
│ │ 需求提取 │ │ Split 判断 │ │ 围串标嫌疑 │ │
│ ├────────────┤ ├────────────┤ ├──────────────┤ │
│ │ 财报抽取 │ │ Rethink复核 │ │ 废标条款审查 │ │
│ ├────────────┤ ├────────────┤ ├──────────────┤ │
│ │ 合规审查 ──┼──┐ │ Fulltext │ │ 错别字异常 │ │
│ ├────────────┤ │ └────────────┘ ├──────────────┤ │
│ │ 数据提取 │ │ │ 报价雷同 │ │
│ ├────────────┤ │ └──────────────┘ │
│ │ Code Agent │ │ │
│ ├────────────┤ │ ┌──────────────────────┐ │
│ │ 评分解释 │ │ │ 技术评审 Agent │ │
│ └────────────┘ │ │ (Dify Workflow) │ │
│ │ │ LLM 节点链 │ │
│ 合规不通过 ─────┘ └──────────────────────┘ │
│ 跳过评分 │
└───────────────────────────────────────────────────────────────────────────┘
│ │
│ Agent 通信方式 │
▼ ▼
┌──────────────────────┐ ┌──────────────────────────┐
│ 1. Workflow 传参 │ │ 2. 数据库共享状态 │
│ 前一个 Activity │ │ 文档→BidFile 表 │
│ 输出=后一个 Activity│ │ KV提取→PointKVAnalysis │
│ 的输入 │ │ 评审→BidEvaluation 表 │
└──────────────────────┘ └──────────────────────────┘

两层编排:Temporal 管宏观流程(Activity 串联/并行/Signal/Timer),PocketFlow 或 Dify 管 Agent 内部推理链。通信以 Workflow 传参和数据库共享为主,Agent 之间不直接调用。