- 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 仍为生效版本,工具白名单未被顶掉
15 KiB
业务 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. 未解决项(需要底座负责人决定)
常驻第二个 admin:已解决。双人复核已取消,单管理员9003即可完成create → submit → review → activate全流程(见 3.2)。fund_market无管理入口:控制台无法通过 API 调整行情允许代码表;当前依赖工具内的安全默认值。:已解决。已补激活入口 (agent_intent_config不可激活reviews/activations/archivals)且运行期读取生效(见 3.1)。docs/05§9.5 未收录agent-intent-configs的GET详情路径:该接口实际存在(用于获取If-Match所需的 ETag),但权威接口文档未列出,属文档待补项。- 没有登录接口(已决定推迟,不是遗漏):当前没有"账号密码换令牌"的接口,令牌由外部按
RS256 用私钥自签(见
docs/26-JWT密钥管理与轮换.md)。决定:等业务 Agent 开发阶段 结束后再补。补的时候只需动签发侧(新增POST /auth/login:校验密码 → 用私钥签令牌), 验签侧JwtAuthenticator与身份解析侧IdentityService都不需要改。 代价与约束:开发阶段凡拿到私钥者都能以9001/9002/9003身份调用(实测无法伪造不存在的 用户、无法使用已禁用账号,边界是"签名有效 + 用户存在且启用"),因此私钥按docs/26第 7 节的红线管理;同时业务代码必须始终只从RequestContext取身份,否则后补登录接口会 从"增量"变成"翻遍所有业务代码"。