Files
group_fqcd_jr/docs/19-业务Agent接入实操(示例验证版).md
qyqy fb7d2f7b6d merge: 跟进架构师最新 qyqy_develop(38 提交)
- model_gateway 冲突取并集:保留本线对 intent_classification→chat 的修正与空集回退,
  并入架构师补充的 5 个风控 task_type;未登记 task_type 的告警留痕一并保留
- docs/25 撞号(本人 JWT 文档 vs 架构师风控评审报告)→ 本人让号到 docs/26,同步 docs/19 引用
- 架构师恢复的 5 份编号文档(04/06/10/13/99)保留其版本(那 5 份已无引用,仅编号占位)
- bootstrap/model_gateway/docs/05/test_risk_agent_contract 自动合并成功

测试:1013 passed / 1 failed(既有空集缺陷)
真机:知识问答 succeeded 且声明仅 1 条;画像问答 succeeded;三端点 403/201/200/404 全绿
配置:release 216 仍为生效版本,工具白名单未被顶掉
2026-09-11 15:40:32 +08:00

15 KiB
Raw Permalink Blame History

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

⚠️ 内容仍有效,仅命令路径陈旧:文中用 D:\\conda\\envs\\jr_py313\\python.exe,本机实际为 .\\.venv\\Scripts\\python.exe。示例 FundQueryDemoAgent 确在 bootstrap.py 注册。

(此批注由 2026-09-11 只读审计加入;原文未改动。详见 docs/superpowers/ARCHIVE-2026-09-11-文档清理归档.md §3)

版本: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 用配置项 JWT_PRIVATE_KEY_PATH 指向的私钥按 RS256 自签, 参考 tools/acceptance_check.py::token())。还没有密钥就先跑一次 python tools/generate_jwt_keys.py --out-dir config/jwt/dev(详见 docs/26-JWT密钥管理与轮换.md):

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),但权威接口文档未列出,属文档待补项。
  5. 没有登录接口(已决定推迟,不是遗漏):当前没有"账号密码换令牌"的接口,令牌由外部按 RS256 用私钥自签(见 docs/26-JWT密钥管理与轮换.md)。决定:等业务 Agent 开发阶段 结束后再补。补的时候只需动签发侧(新增 POST /auth/login:校验密码 → 用私钥签令牌), 验签侧 JwtAuthenticator 与身份解析侧 IdentityService 都不需要改。 代价与约束:开发阶段凡拿到私钥者都能以 9001/9002/9003 身份调用(实测无法伪造不存在的 用户、无法使用已禁用账号,边界是"签名有效 + 用户存在且启用"),因此私钥按 docs/26 第 7 节的红线管理;同时业务代码必须始终只从 RequestContext 取身份,否则后补登录接口会 从"增量"变成"翻遍所有业务代码"。