Files
group_fqcd_jr/docs/19-业务Agent接入实操(示例验证版).md
T
lzf_0626 6516ccb385 feat: 第二版——接口契约对齐 docs/05,修复静默故障与数据库基线
相对第一版 46fc976 的完整变更。组员迁移对照表见 docs/20。

一、对外契约对齐 docs/05(破坏性,共 4 处,组员需按 docs/20 调整)
1) 配置发布端点改为文档规定的复数资源名:submit→validations、
   approve→reviews(需 body decision)、activate→activations、
   rollback→rollbacks;第一版这 4 个动词式路径 docs/05 从未定义过。
2) 错误码由 8 个笼统码改为 15 个具体语义码(FORBIDDEN→AGENT_PERMISSION_DENIED、
   UNAUTHORIZED→AUTHENTICATION_REQUIRED、CONFLICT→RESOURCE_VERSION_CONFLICT、
   RESOURCE_NOT_FOUND→RUN_NOT_FOUND/SESSION_NOT_FOUND 等),
   输入类错误状态码 400→422。
3) POST /api/v1/agent-runs 与 GET /api/v1/agent-runs/{run_id} 统一为
   {data, meta} 信封(data 内字段名与语义未变)。
4) 错误响应体统一为 {error:{code,message,retryable,field_errors}, meta:{trace_id}},
   不再返回 FastAPI 默认的 {"detail": ...}。

二、数据库基线与约束
新增 39 张表的基线迁移(链根)与联合唯一键纠偏(4 张表、删 8 增 4,幂等收敛);
撤下 config_release 的双人复核 CHECK(应用层已允许自审,审核节点保留,
自审如实写入 reviewer_id);记忆 active key 生成列与唯一键;
activate 开始记录 supersedes_release_id 使版本链可追溯。
docs/00 基线未修改,未重命名或删除任何表与字段。

三、修复会静默出错或无报错的缺陷
- 跑完集成测试后平台会静默失去生效配置:清理只删自己创建的版本,却没有恢复被它
  顶成 superseded 的原生效版本,且审计一并删除因而完全无痕,表现为所有工具被拒
  但没有任何报错。已修清理逻辑并加恢复。
- Worker 单轮异常导致进程退出;记忆抽取调用方的“事务已开始”异常;
  召回缓存丢失 degraded 标记;连接时区未生效导致 created_at/updated_at 差 8 小时;
  .env 与 os.getenv 密钥来源分裂导致“没有可用的已批准模型端点”。
- 记忆信号识别漏判与跨键误命中;SSE 未带 Accept 的协商行为。

四、功能补齐
记忆链路 P1/P2/P3(抽取、受控词表、召回与缓存、生命周期级联及投影事件)、
fin_* 场内交易只读 ORM 层、agent_intent_config 状态流转并在运行期真正生效、
限流(Redis 固定窗口、故障一律放行)、游标校验、trace_id 中间件、
示例业务 Agent fund_query_demo 与一键端到端验证脚本,以及审计/指纹/迁移状态工具。

五、文档与验证
新增 docs/19(业务 Agent 接入实操)、docs/20(第一版迁移指南)与 docs/evidence 证据;
docs/01/02/06/08/09/17 同步实现现状。

验证结果:ruff 通过、mypy 103 文件无错、unit+contract 447 passed、
integration 29 passed、acceptance_check --production 7 PASS、
demo_agent_e2e 9/9 PASS(含失败关闭反证)。
2026-09-10 15:55:54 +08:00

14 KiB
Raw Blame History

业务 Agent 接入实操(示例验证版)

版本:v1.0|适用对象:要在本底座上开发业务 Agent 的组员 本文所有步骤均在真实环境执行过:真实 MySQL、真实 JWT、真实模型端点(deepseek-flash)、 真实外部行情接口。示例 Agent 已注册进生产装配,端到端与反证测试全部通过。 权威接口定义以《05-接口文档.md》为准,数据库以《00-新数据库基线设计.md》为准。

0. 先看结论:这次验证的是什么

项目 值
示例 Agent app/service/agent/implementations/fund_query_demo.py,类 FundQueryDemoAgent
agent_type fund_query_demo
意图 fund_quote(只有一个意图,避免意图分类返回未声明值)
工具声明 allowed_tools=("query_fund_quote",),只读、权限 fund:quote:read
注册位置 app/service/agent/bootstrap.py 的 register_business_agents(factory)
配置读取路径 当前 status='active' 的 config_release + namespace='agent_tools' + config_key='<agent_type>:<intent>',与代码 allowed_tools 取交集
一键复现 python tools/demo_agent_e2e.py(发布配置 → 正常路径 → 反证 → 恢复 → 清理)

一键脚本实测结果:9 项断言全 PASS,0 FAIL。

1. 写 Agent 代码(1 个文件)

新建 app/service/agent/implementations/<你的 Agent>.py,只做三件事:声明定义、实现 handle()、 通过 self.call_tool(...) 调公共工具。不要覆盖 execute()/resolve_config()/call_tool() 等治理方法 (BaseAgent.__init_subclass__ 会直接抛 TypeError)。

class FundQueryDemoAgent(BaseAgent):
    definition = AgentDefinition(
        agent_type="fund_query_demo",
        version="1.0.0",
        allowed_roles=("customer", "advisor", "operator", "admin"),
        allowed_portals=("api",),            # JWT 身份解析后的 portal 固定是 api
        allowed_tools=("query_fund_quote",), # 代码上限,发布配置只能缩小
        supported_intents=("fund_quote",),   # 必须与配置里的 config_key 后缀一致
    )

    async def handle(self, request: AgentRequest, context: RequestContext) -> CoreResult:
        output = await self.call_tool(
            "query_fund_quote",
            {"fund_codes": list(extract_fund_codes(request.message)), "limit": 1},
            intent="fund_quote",          # 必须来自 supported_intents
            context=context,
        )
        return CoreResult(text=...)       # tool_calls / source_references 由底座附加,不要伪造

要点:

  • allowed_tools 与 supported_intents 是代码上限,数据库配置只能收窄;
  • 工具返回 degraded=true 时必须在文案里显式提示降级,不得表述为成交/委托/持仓;
  • 业务代码不导入 httpx、不连数据库、不读环境变量密钥。

2. 在 bootstrap 注册(1 行)

编辑 app/service/agent/bootstrap.py,在构造 AgentFactory 之后统一登记:

def register_business_agents(factory: AgentFactory) -> None:
    factory.register(
        FundQueryDemoAgent.definition,
        lambda _context: FundQueryDemoAgent(FundQueryDemoAgent.definition),
    )

HTTP 服务与 Worker 共用 get_agent_factory()(lru_cache 单例),注册一次两边同时生效。 未注册的 agent_type 受理时返回 404 AGENT_TYPE_NOT_FOUND。

3. 发布配置(这一步最容易漏,漏了就工具必然被拒)

工具白名单不在代码里,而在「当前 active 的 config_release」里。管理 API 的写入口是 /api/v1/admin/...,全部需要 JWT + Idempotency-Key(16–128 位 ASCII);状态转换还需要 If-Match(值取自上一次响应的 ETag,或 GET 单条资源时的 ETag)。

3.1 五个调用(顺序不能变)

POST /api/v1/admin/config-releases                                   # 建 draft 版本
POST /api/v1/admin/config-releases/{id}/platform-config-items        # 写工具白名单
POST /api/v1/admin/config-releases/{id}/validations                  # 提交复核(构造人本人)
POST /api/v1/admin/config-releases/{id}/reviews                      # 复核(必须是别人!)
POST /api/v1/admin/config-releases/{id}/activations                  # 激活

请求体(真实可复制):

{ "release_no": "demo-fund-<随机>", "title": "示例 Agent 接入配置",
  "change_summary": "为示例 Agent 发布意图工具白名单" }

{ "namespace": "agent_tools", "item_key": "fund_query_demo:fund_quote",
  "value_json": { "allowed_tools": ["query_fund_quote"] }, "schema_version": "1" }

{ "decision": "approved", "comment": "接入验证" }

意图配置(可选,走 POST /api/v1/admin/agent-intent-configs):agent_type、intent_code、 intent_name、examples、allowed_tools。写出的记录是 draft,需要再走 .../{config_id}/reviews → .../{config_id}/activations 才能生效(该表状态集只有 draft/approved/active/archived,没有 disabled,停用走 archivals)。激活后运行期会真正读取它: 配置里的意图名、描述、示例和 classifier_instruction 会拼进分类 prompt, needs_clarification 的判定阈值也取自该意图的 confidence_threshold(无配置时回落 0.65)。 也就是说它会改变分类行为——示例写得与该意图不符,分类结果就会跟着偏。

3.2 审核:单管理员可直接自审(已取消双人复核)

ConfigReleaseService.approve 不再要求"审核人不同于创建人":单管理员部署下创建人可以审核 自己创建的版本,且 reviewer_id 会如实写成自己(数据库侧原本的 chk_config_release_separation 约束已由迁移 20260910_drop_review_separation 撤下)。

但要注意**"审核"这个节点本身不能跳过**:草稿必须先 submit 到 pending_review 才能 approve,否则报 release is not pending review;未 approved 的版本也不能 activate。

因此接入验证不需要第二个 admin 身份,只用常驻的 9003 就能走完 create → submit → review → activate(tools/demo_agent_e2e.py 现在就是这么跑的)。 早先文档要求临时启用 9004 当复核人,那个做法已不再需要,相关临时身份也已禁用。

3.3 核对配置是否真的生效(只读 SQL)

SELECT r.id, r.release_no, r.status FROM config_release r WHERE r.status='active';
SELECT i.release_id, i.namespace, i.config_key, i.value_json
FROM platform_config_item i JOIN config_release r ON r.id = i.release_id
WHERE r.status='active' AND i.namespace='agent_tools';

期望看到唯一 active 版本,且 config_key='fund_query_demo:fund_quote'、 value_json={"allowed_tools": ["query_fund_quote"]}。

4. 端到端验证

先停掉常驻 Worker:WorkerRuntime.run_once 会在共享的 agent_run 队列上抢 run; 若它抢先执行,你会看到与本次验证无关的执行路径(探针/旧注册),得到假失败。

# 一键:发布配置 → 正常路径 → 反证 → 恢复 → 清理 run 数据
D:\conda\envs\jr_py313\python.exe tools\demo_agent_e2e.py

# 只跑一次真实 run(受理 202 → 手动执行 → 打印工具调用与审计)
D:\conda\envs\jr_py313\python.exe C:\Users\...\e2e_run_check.py --label 手工验证

单条链路的手工命令(客户身份 9001,JWT 用 config/jwt/jwt-private.pem 按 RS256 自签, 参考 tools/acceptance_check.py::token()):

POST /api/v1/agent-runs
{ "agent_type":"fund_query_demo", "message":"帮我看下 159382 这只场内基金的行情",
  "session_id":"demo-fund-<uuid>", "idempotency_key":"<16-128 位 ASCII>" }
→ 202 + run_id/trace_id
GET  /api/v1/agent-runs/{run_id}      → status=succeeded,result.tool_calls.calls[0].status=succeeded
GET  /api/v1/agent-runs/{run_id}/events → event: start / tools / replace / done

本次实测证据(tools/demo_agent_e2e.py 输出):

正常路径:受理 202 → status=succeeded
工具调用 {"calls":[{"status":"succeeded","tool_name":"query_fund_quote",...}]}
来源引用 [{"source_type":"tool","source_id":"<trace_id>:query_fund_quote",...}]
审计 interaction_audit 1 条:agent.tool_executed detail.status=succeeded reason=ok

5. 反证测试:证明配置是必需的,而不是碰巧能跑

新建并激活一个不含 agent_tools 配置项的 release(其余步骤与 3.1 完全相同,只是跳过写配置项), 再跑同一条链路:

受理仍为 202(受理阶段不看工具白名单)
run 终态 failed,error_code=AGENT_PERMISSION_DENIED
interaction_audit 1 条:agent.tool_executed detail.status=denied
                        reason="工具不在当前意图白名单"

即:没有 active 发布版本或没有对应配置项时,工具白名单为空 → 工具被拒绝(失败关闭)。 反证完成后务必重新激活带白名单的版本,否则线上工具会一直不可用。 一键脚本会自动做「反证 → 恢复」两步。

6. 常见坑(全部实测踩过)

坑 现象 处理
工具白名单必须在 active release 里 工具调用被拒、run failed / AGENT_PERMISSION_DENIED,审计 denied 确认 config_release.status='active' 且该版本有 agent_tools/<agent_type>:<intent>;draft/approved 版本不算
只能改 draft 版本 对 active 版本调 PUT 返回 InvalidStateError(只能编辑草稿或停用版本) 改配置必须新建版本再走复核激活,不要试图改 active 行
状态转换缺 If-Match 409 CONFIG_VERSION_CONFLICT 每次转换前先 GET 拿最新 ETag;复核后 ETag 会变
写接口缺 Idempotency-Key 必须提供 16-128 位 ASCII Idempotency-Key 每次写请求带新的随机 key
双人复核(已取消) 旧行为会报 creator cannot review own release 现在单管理员可自审(见 3.2);但审核节点仍不可跳过:草稿必须先 submit 到 pending_review
SSE 未带 Accept 不是 406:未带(或空)Accept 被视为可接受,返回 200 text/event-stream;显式写 Accept: application/json 才返回 406 SSE_NOT_ACCEPTABLE 客户端若要 JSON,请不要去请求 /events;SSE 客户端建议显式带 Accept: text/event-stream
验收/联调前必须停 Worker 常驻 Worker 与脚本共享 agent_run 队列,抢走 run 后用另一条执行路径,导致假失败 先停常驻 Worker,再手动 WorkerRuntime().execute(run_id)
assigned_at 时区/舍入 角色分配写“当前时间”可能因秒级进位落在未来,身份解析拿不到任何权限(roles 为空,而非报错) 种子/授权写入用 NOW(6) - INTERVAL 5 SECOND(参考 tools/seed_test_rbac.py 的 now - 5s)
意图分类会真实调用模型 每次 run 都会有一次模型调用(当前按 active model_endpoint_config 解析,不区分 task_type);模型若返回未声明的意图会抛 ValidationAgentError → run failed 只声明你会处理的少量意图;needs_clarification=true 时不要当成确定意图
fund_market 配置没有管理 API 入口 ItemPayload.namespace 只接受 agent_tools/memory/relationship/runtime;admin_service 里虽支持 fund_market 字段校验,但 HTTP 层进不来 行情工具在配置缺失时使用安全默认代码表(含 159382 等场内 ETF),不改配置即可用;若必须调整白名单,只能核对后直接写库并在变更记录中说明
interaction_audit.detail 读出来是字符串 直接 .get() 会 AttributeError 用 JSON_UNQUOTE(JSON_EXTRACT(detail,'$.trace_id')) 过滤,取回后 json.loads
清理测试数据时别删配置 删掉 config_release/platform_config_item 会让工具立刻失败关闭 只删 agent_run/conversation_message/domain_event_outbox/outbox_delivery/request_idempotency/本 run 的 interaction_audit。更隐蔽的陷阱:测试期间激活临时版本会把平台原有的 active 顶成 superseded,清理时若只删自己创建的版本,平台就停在"零个 active 版本"——工具白名单随之变空集、所有工具被拒,而且全程没有任何报错。删完必须把原先生效的版本恢复为 active(见 tests/integration/test_config_release_mysql.py 的 finally)

7. 提交前检查

D:\conda\envs\jr_py313\python.exe -m pytest -q tests/unit tests/contract
D:\conda\envs\jr_py313\python.exe -m pytest -q tests/integration
D:\conda\envs\jr_py313\python.exe -m ruff check app tests
D:\conda\envs\jr_py313\python.exe -m mypy app
D:\conda\envs\jr_py313\python.exe tools\acceptance_check.py --production
D:\conda\envs\jr_py313\python.exe tools\demo_agent_e2e.py

示例 Agent 的接入契约测试见 tests/contract/test_fund_query_demo_agent_contract.py (注册可扫描、意图/工具声明自洽、白名单失败关闭、成功调用带出记录与可校验来源引用)。

8. 未解决项(需要底座负责人决定)

  1. 常驻第二个 admin:已解决。双人复核已取消,单管理员 9003 即可完成 create → submit → review → activate 全流程(见 3.2)。
  2. fund_market 无管理入口:控制台无法通过 API 调整行情允许代码表;当前依赖工具内的安全默认值。
  3. agent_intent_config 不可激活:已解决。已补激活入口 (reviews/activations/archivals)且运行期读取生效(见 3.1)。
  4. docs/05 §9.5 未收录 agent-intent-configs 的 GET 详情路径:该接口实际存在(用于获取 If-Match 所需的 ETag),但权威接口文档未列出,属文档待补项。