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

235 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 业务 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`)。
```python
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` 之后统一登记:
```python
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 五个调用(顺序不能变)
```text
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 # 激活
```
请求体(真实可复制):
```json
{ "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)
```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;
若它抢先执行,你会看到与本次验证无关的执行路径(探针/旧注册),得到假失败。
```powershell
# 一键:发布配置 → 正常路径 → 反证 → 恢复 → 清理 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`):
```text
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` 输出):
```text
正常路径:受理 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 完全相同,只是跳过写配置项),
再跑同一条链路:
```text
受理仍为 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. 提交前检查
```powershell
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` 取身份,否则后补登录接口会
从"增量"变成"翻遍所有业务代码"。