- 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 仍为生效版本,工具白名单未被顶掉
235 lines
15 KiB
Markdown
235 lines
15 KiB
Markdown
# 业务 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` 取身份,否则后补登录接口会
|
||
从"增量"变成"翻遍所有业务代码"。
|