feat(platform): extract规格会话级锚定(Redis db4,键=pipeline:user:session隔离)+apply双工具use_last_extract/overrides白名单——根治LLM转述大JSON必编造结构(实测三轮编造);报错升级列期望键给出路;提示词加自救规则

This commit is contained in:
yumoqing 2026-09-05 21:25:48 +08:00
parent 885a52c7d8
commit fb3447e56e

View File

@ -44,6 +44,106 @@ OWNER_ORG = "owner"
# 其他协议的适配模板会存入 llm_api_profile 备查,但运行时暂不渲染。 # 其他协议的适配模板会存入 llm_api_profile 备查,但运行时暂不渲染。
RUNTIME_PROTOCOLS = ("openai_compat", "dashscope_async") RUNTIME_PROTOCOLS = ("openai_compat", "dashscope_async")
# ────────────────────── 会话级规格缓存(extract 锚定,2026-09-05)──────────────────────
# 根因:apply_llm_config 要求 LLM 把 extract 返回的大 JSON 规格逐字复制进参数,
# 模型「转述」大 JSON 必然编造结构(实测三轮全编造 vendor{}/api_profile{} 等契约外字段)。
# 对策:extract 成功后把规格锚定到「会话级缓存」,apply/apply_model_pricing 用
# use_last_extract=true 直接取用 + 少量覆盖项(vendor_name 等),LLM 永不搬运大 JSON。
#
# 会话级隔离(用户硬要求):缓存键 = pipeline_id:user_id:session_id,三段缺一不可,
# 防跨会话/跨用户/跨产线串数据。存 Redis db4(治理命名空间,gateway 已在用),
# 跨 worker 进程不丢;TTL 兜底过期。Redis 不可用时降级进程内字典(仍按同键隔离)。
_SPEC_CACHE_TTL = 7200 # 规格缓存 2 小时(一次配置会话足够)
_SPEC_CACHE_PREFIX = "llm_spec"
_spec_cache_local = {} # Redis 不可用时的进程内降级(同样按隔离键)
def _spec_cache_key(ctx):
"""会话级隔离键:pipeline_id:user_id:session_id。任一段缺失返回 ''(拒绝缓存)。"""
pl = (ctx.get("pipeline_id") or "").strip()
uid = (ctx.get("user_id") or "").strip()
sid = (ctx.get("session_id") or "").strip()
if not (pl and uid and sid):
return ''
return "%s:%s:%s:%s" % (_SPEC_CACHE_PREFIX, pl, uid, sid)
def _spec_redis():
"""复用治理命名空间 Redis db4(与 gateway 限流同库,会话态用 db3 不冲突)。"""
try:
import redis as _r
try:
from appPublic.jsonConfig import getConfig
url = (getConfig().website or {}).get('session_redis', {}).get('url', '')
except Exception:
url = ''
if not url:
url = 'redis://127.0.0.1:6379/3'
base = url.rsplit('/', 1)[0]
return _r.Redis.from_url(base + '/4', socket_timeout=2)
except Exception:
return None
def _spec_save(ctx, spec):
"""锚定规格到会话缓存。返回 True/False。无隔离键则不缓存(apply 会要求显式传 spec)。"""
key = _spec_cache_key(ctx)
if not key:
return False
try:
payload = json.dumps(spec, ensure_ascii=False)
except Exception:
return False
r = _spec_redis()
if r is not None:
try:
r.set(key, payload, ex=_SPEC_CACHE_TTL)
return True
except Exception:
pass
_spec_cache_local[key] = (payload, time.time() + _SPEC_CACHE_TTL)
return True
def _spec_load(ctx):
"""取本会话锚定的规格。无则返回 None。严格会话级隔离(键含 session_id)。"""
key = _spec_cache_key(ctx)
if not key:
return None
r = _spec_redis()
raw = None
if r is not None:
try:
v = r.get(key)
raw = v.decode('utf-8') if isinstance(v, (bytes, bytearray)) else v
except Exception:
raw = None
if raw is None:
ent = _spec_cache_local.get(key)
if ent and ent[1] > time.time():
raw = ent[0]
elif ent:
_spec_cache_local.pop(key, None)
if not raw:
return None
try:
return json.loads(raw)
except Exception:
return None
def _spec_overlay(base, overrides):
"""覆盖项合并:仅允许少量标量覆盖(vendor_name 等),不做大 JSON 搬运。
overrides 只认白名单键,防止 LLM 借覆盖项塞回编造结构。
"""
allowed = {'vendor_name', 'base_url', 'protocol', 'doc_url', 'doc_notes'}
out = dict(base or {})
for k in allowed:
if k in (overrides or {}) and str(overrides.get(k) or '').strip():
out[k] = str(overrides[k]).strip()
return out
# ────────────────────── 权限门禁(代码层) ────────────────────── # ────────────────────── 权限门禁(代码层) ──────────────────────
@ -297,7 +397,18 @@ async def _h_extract_llm_api_spec(sor, params, ctx):
spec = json.loads(m.group(0)) spec = json.loads(m.group(0))
except Exception: except Exception:
return "LLM 输出不是合法 JSON:%s" % txt[:300] return "LLM 输出不是合法 JSON:%s" % txt[:300]
return json.dumps(spec, ensure_ascii=False) # 会话级锚定(2026-09-05):规格存会话缓存(键含 session_id,跨会话隔离),
# 后续 apply_llm_config / apply_model_pricing 用 use_last_extract=true 取用——
# LLM 不再搬运/转述大 JSON(转述必编造结构,实测三轮全编造)。
anchored = _spec_save(ctx, spec)
out = dict(spec)
if anchored:
out["__anchored__"] = ("规格已锚定到本会话缓存。下一步调用 apply_llm_config 与 "
"apply_model_pricing 时传 {\"use_last_extract\": true},"
"需改动只传覆盖项(overrides,仅 vendor_name/base_url/"
"protocol/doc_url/doc_notes 白名单键)。"
"禁止把本规格复制进 spec 参数——复制即编造。")
return json.dumps(out, ensure_ascii=False)
# ────────────────────── 工具 4:写入配置(幂等) ────────────────────── # ────────────────────── 工具 4:写入配置(幂等) ──────────────────────
@ -310,21 +421,52 @@ def _f(v, default=0.0):
async def _h_apply_llm_config(sor, params, ctx): async def _h_apply_llm_config(sor, params, ctx):
"""按提取的规格写库:供应商(复用/新建)+ 适配模板 + 模型(含定价)。 """按提取的规格写库:供应商(复用/新建)+ 适配模板 + 模型。
幂等:供应商按名称复用;模型按 (vendor, vendor_model_id) 复用—— 幂等:供应商按名称复用;模型按 (vendor, vendor_model_id) 复用——
已存在则更新定价,不重复创建。 已存在则更新,不重复创建。
规格来源(2026-09-05 防编造改造):
use_last_extract=true(推荐)→ 取本会话 extract 锚定的规格,
overrides 白名单覆盖(vendor_name 等少量标量);
否则用 params.spec(LLM 手传,历史路径)——结构不符时报错列出期望键。
""" """
err = await _require_owner(sor, ctx) err = await _require_owner(sor, ctx)
if err: if err:
return err return err
try: use_last = str(params.get("use_last_extract") or "").lower() in ("true", "1", "yes")
spec = json.loads(params.get("spec") or "{}") overrides = params.get("overrides") or {}
except Exception: if isinstance(overrides, str):
return "spec 不是合法 JSON" try:
overrides = json.loads(overrides)
except Exception:
overrides = {}
spec = None
if use_last:
spec = _spec_load(ctx)
if spec is None:
return ("本会话没有锚定的提取规格——先调 extract_llm_api_spec(成功后规格自动"
"锚定本会话),再传 {\"use_last_extract\": true}。"
"注意会话级隔离:其他会话提取的规格本会话不可见,需在本会话重新提取。")
spec = _spec_overlay(spec, overrides)
else:
try:
spec = json.loads(params.get("spec") or "{}")
except Exception:
return "spec 不是合法 JSON"
# 结构校验(修B:报错给出路,不让模型盲改重试)
if not isinstance(spec, dict) or "vendor_name" not in spec or "models" not in spec:
got = sorted(spec.keys())[:12] if isinstance(spec, dict) else "(非对象)"
return ("spec 结构不符——不要自己编写/改写规格结构。期望顶层键:"
"vendor_name / base_url / endpoints / protocol / chat_path / models / "
"pricing / doc_url / doc_notes(extract_llm_api_spec 的原样输出)。"
"你传入的顶层键:%s。正确做法:调 extract_llm_api_spec 后传 "
"{\"use_last_extract\": true}(规格已锚定本会话,禁止复制转述),"
"需改动加 overrides(仅 vendor_name/base_url/protocol/doc_url/doc_notes)。"
% got)
vendor_name = (spec.get("vendor_name") or "").strip() vendor_name = (spec.get("vendor_name") or "").strip()
if not vendor_name: if not vendor_name:
return "spec 缺少 vendor_name" return "spec 缺少 vendor_name(可用 overrides.vendor_name 指定)"
endpoints = spec.get("endpoints") or [] endpoints = spec.get("endpoints") or []
if not endpoints and spec.get("base_url"): if not endpoints and spec.get("base_url"):
endpoints = [{"base_url": spec["base_url"], "region": "domestic", "timeout": 60}] endpoints = [{"base_url": spec["base_url"], "region": "domestic", "timeout": 60}]
@ -657,14 +799,28 @@ async def _h_apply_model_pricing(sor, params, ctx):
幂等键:模型已有 ppid 且方案描述含同一出处 URL → 更新时序(拉链); 幂等键:模型已有 ppid 且方案描述含同一出处 URL → 更新时序(拉链);
否则新建方案。价格只来自文档原文(提取层强制 doc_quote)。 否则新建方案。价格只来自文档原文(提取层强制 doc_quote)。
规格来源(2026-09-05 防编造改造):use_last_extract=true → 取本会话
extract 锚定规格(含 pricing/doc_url),禁止 LLM 复制转述大 JSON。
""" """
err = await _require_owner(sor, ctx) err = await _require_owner(sor, ctx)
if err: if err:
return err return err
try: use_last = str(params.get("use_last_extract") or "").lower() in ("true", "1", "yes")
spec = json.loads(params.get("spec") or "{}") if use_last:
except Exception: spec = _spec_load(ctx)
return "spec 不是合法 JSON" if spec is None:
return ("本会话没有锚定的提取规格——先调 extract_llm_api_spec(成功后规格自动"
"锚定本会话,含 pricing 与 doc_url),再传 {\"use_last_extract\": true}。"
"会话级隔离:其他会话的规格本会话不可见。")
else:
try:
spec = json.loads(params.get("spec") or "{}")
except Exception:
return "spec 不是合法 JSON"
if not isinstance(spec, dict) or "pricing" not in spec:
return ("spec 结构不符——不要自己编写规格。正确做法:extract_llm_api_spec 后传 "
"{\"use_last_extract\": true}(pricing 与 doc_url 已锚定本会话)。")
pricing = spec.get("pricing") or {} pricing = spec.get("pricing") or {}
items = pricing.get("items") or [] items = pricing.get("items") or []
if not items: if not items:
@ -996,15 +1152,22 @@ PLATFORM_TOOLS = [
), ),
ToolDefinition( ToolDefinition(
name="apply_llm_config", name="apply_llm_config",
description="按提取的规格写入模型治理配置:供应商/端点/适配模板/模型。幂等——已有模型只更新。定价不在此工具(用 apply_model_pricing)。仅owner组织角色可用。", description="按提取的规格写入模型治理配置:供应商/端点/适配模板/模型。幂等。推荐 use_last_extract=true 取本会话锚定规格(禁止把规格复制进 spec——复制即编造);需改动加 overrides(仅 vendor_name/base_url/protocol/doc_url/doc_notes)。仅owner组织角色可用。",
parameters={"spec": "extract_llm_api_spec 返回的 JSON 规格"}, parameters={
"use_last_extract": "true=用本会话 extract_llm_api_spec 锚定的规格(推荐,会话级隔离)",
"overrides": "覆盖项 JSON(白名单:vendor_name/base_url/protocol/doc_url/doc_notes,如用户要求改供应商名)",
"spec": "(仅无锚定时的兜底)extract 返回的 JSON 规格原文",
},
category="platform", category="platform",
requires_confirmation=True, requires_confirmation=True,
), ),
ToolDefinition( ToolDefinition(
name="apply_model_pricing", name="apply_model_pricing",
description="定价自动导入:按提取规格的 pricing 建定价方案(pricing_program+时序YAML)并挂模型 ppid。幂等(已有 ppid 拉链更新时序)。一个定价方案只服务一个模型;定价相同的模型共享同一 ppid。价格必须来自文档原文(每条带 doc_quote 否则拒绝)。仅owner组织角色可用。", description="定价自动导入:建定价方案(pricing_program+时序YAML)并挂模型 ppid。幂等(内容无变化跳过;同日原地更新;历史行才拉链——时序表始终只一条有效行)。一个定价方案只服务一个模型;定价相同的模型共享同一 ppid。推荐 use_last_extract=true 取本会话锚定规格(含 pricing/doc_url),禁止复制转述。仅owner组织角色可用。",
parameters={"spec": "extract_llm_api_spec 返回的 JSON 规格(含 pricing 与 doc_url)"}, parameters={
"use_last_extract": "true=用本会话锚定规格的 pricing+doc_url(推荐)",
"spec": "(仅无锚定时的兜底)extract 返回的 JSON 规格原文",
},
category="platform", category="platform",
requires_confirmation=True, requires_confirmation=True,
), ),
@ -1043,12 +1206,14 @@ PLATFORM_PROMPT = """
## 模型自动配置全链工作流(用户给文档 URL 要求配置模型时,按序独立完成) ## 模型自动配置全链工作流(用户给文档 URL 要求配置模型时,按序独立完成)
1. **先 `load_skill` 加载 `model-auto-config`**(含媒体转换铁律/能力分类先行/定价新模式),严格执行 1. **先 `load_skill` 加载 `model-auto-config`**(含媒体转换铁律/能力分类先行/定价新模式),严格执行
2. `fetch_model_doc` 抓取官方文档(返回文本末尾带 [出处URL] 行) 2. `fetch_model_doc` 抓取官方文档(返回文本末尾带 [出处URL] 行)
3. `extract_llm_api_spec` 通读文档提取规格:端点/协议/能力/sync_mode/async_steps, 3. `extract_llm_api_spec` 通读文档提取规格——**成功后规格自动锚定到本会话缓存**
以及 **pricing(每条价格带 doc_quote 文档原文)和 doc_url(照抄出处行)** (会话级隔离),返回里有 __anchored__ 提示
4. 把提取摘要给用户确认(端点/能力/定价原文/出处),确认后: 4. 把提取摘要给用户确认(端点/能力/定价原文/出处),确认后:
5. `apply_llm_config` 落库供应商/适配模板/模型(模型描述自动带 [出处:URL]) 5. `apply_llm_config` 传 **{"use_last_extract": true}**(用户要求改名等只加
6. `apply_model_pricing` 建定价方案并挂 ppid(定价描述带出处URL+文档原文; overrides 白名单覆盖项,如 {"overrides": {"vendor_name": "阿里云百炼"}})。
一个方案只服务一个模型,无 model 维度无 filters,维度平铺;幂等拉链更新) **绝对禁止把规格 JSON 复制/转述进 spec 参数——转述必编造结构(历史教训:
三轮全编造 vendor{}/api_profile{} 等不存在的字段)。**
6. `apply_model_pricing` 同样传 **{"use_last_extract": true}**(定价+出处已锚定)
7. `test_model_call` 真实调用测试(前置:账号已配 api_key,没配则如实告知用户去补, 7. `test_model_call` 真实调用测试(前置:账号已配 api_key,没配则如实告知用户去补,
不要伪造结果)——生成类模型传 params(image_file/resolution/duration 等) 不要伪造结果)——生成类模型传 params(image_file/resolution/duration 等)
8. `check_model_accounting` 检查记账(传上一步的 task_ref): 8. `check_model_accounting` 检查记账(传上一步的 task_ref):
@ -1057,6 +1222,12 @@ PLATFORM_PROMPT = """
- failed → 按 reason/hint 处置(未映射产品→告知用户在产品管理导入产线模型) - failed → 按 reason/hint 处置(未映射产品→告知用户在产品管理导入产线模型)
9. 汇报:配置/定价/测试/记账四段结果 + 出处 URL + 遗留事项(如模板需人工核对项) 9. 汇报:配置/定价/测试/记账四段结果 + 出处 URL + 遗留事项(如模板需人工核对项)
## 工具报错自救规则
- 报「spec 结构不符/缺少 xxx」→ **不要改写结构重试**,改用 use_last_extract=true;
本会话没锚定就重新走 fetch→extract(锚定是会话级的,跨会话不可见)
- 报「能力分类未登记」→ 如实转告用户需先加能力分类(这是人工评审门,不要绕过)
- 报「media_audit/runtime_note/需人工核对」警告 → 原样转告用户,不假装完成
## 硬规则 ## 硬规则
- **定价只允许来自文档原文**(doc_quote 强制),禁止编造/换算/推导 - **定价只允许来自文档原文**(doc_quote 强制),禁止编造/换算/推导
- **出处必留**:模型注册表描述字段与定价方案描述字段都必须保存文档 URL - **出处必留**:模型注册表描述字段与定价方案描述字段都必须保存文档 URL