feat(platform): extract规格会话级锚定(Redis db4,键=pipeline:user:session隔离)+apply双工具use_last_extract/overrides白名单——根治LLM转述大JSON必编造结构(实测三轮编造);报错升级列期望键给出路;提示词加自救规则
This commit is contained in:
parent
885a52c7d8
commit
fb3447e56e
@ -44,6 +44,106 @@ OWNER_ORG = "owner"
|
||||
# 其他协议的适配模板会存入 llm_api_profile 备查,但运行时暂不渲染。
|
||||
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))
|
||||
except Exception:
|
||||
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:写入配置(幂等) ──────────────────────
|
||||
@ -310,21 +421,52 @@ def _f(v, default=0.0):
|
||||
|
||||
|
||||
async def _h_apply_llm_config(sor, params, ctx):
|
||||
"""按提取的规格写库:供应商(复用/新建)+ 适配模板 + 模型(含定价)。
|
||||
"""按提取的规格写库:供应商(复用/新建)+ 适配模板 + 模型。
|
||||
|
||||
幂等:供应商按名称复用;模型按 (vendor, vendor_model_id) 复用——
|
||||
已存在则更新定价,不重复创建。
|
||||
已存在则更新,不重复创建。
|
||||
|
||||
规格来源(2026-09-05 防编造改造):
|
||||
use_last_extract=true(推荐)→ 取本会话 extract 锚定的规格,
|
||||
overrides 白名单覆盖(vendor_name 等少量标量);
|
||||
否则用 params.spec(LLM 手传,历史路径)——结构不符时报错列出期望键。
|
||||
"""
|
||||
err = await _require_owner(sor, ctx)
|
||||
if err:
|
||||
return err
|
||||
try:
|
||||
spec = json.loads(params.get("spec") or "{}")
|
||||
except Exception:
|
||||
return "spec 不是合法 JSON"
|
||||
use_last = str(params.get("use_last_extract") or "").lower() in ("true", "1", "yes")
|
||||
overrides = params.get("overrides") or {}
|
||||
if isinstance(overrides, str):
|
||||
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()
|
||||
if not vendor_name:
|
||||
return "spec 缺少 vendor_name"
|
||||
return "spec 缺少 vendor_name(可用 overrides.vendor_name 指定)"
|
||||
endpoints = spec.get("endpoints") or []
|
||||
if not endpoints and spec.get("base_url"):
|
||||
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 → 更新时序(拉链);
|
||||
否则新建方案。价格只来自文档原文(提取层强制 doc_quote)。
|
||||
|
||||
规格来源(2026-09-05 防编造改造):use_last_extract=true → 取本会话
|
||||
extract 锚定规格(含 pricing/doc_url),禁止 LLM 复制转述大 JSON。
|
||||
"""
|
||||
err = await _require_owner(sor, ctx)
|
||||
if err:
|
||||
return err
|
||||
try:
|
||||
spec = json.loads(params.get("spec") or "{}")
|
||||
except Exception:
|
||||
return "spec 不是合法 JSON"
|
||||
use_last = str(params.get("use_last_extract") or "").lower() in ("true", "1", "yes")
|
||||
if use_last:
|
||||
spec = _spec_load(ctx)
|
||||
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 {}
|
||||
items = pricing.get("items") or []
|
||||
if not items:
|
||||
@ -996,15 +1152,22 @@ PLATFORM_TOOLS = [
|
||||
),
|
||||
ToolDefinition(
|
||||
name="apply_llm_config",
|
||||
description="按提取的规格写入模型治理配置:供应商/端点/适配模板/模型。幂等——已有模型只更新。定价不在此工具(用 apply_model_pricing)。仅owner组织角色可用。",
|
||||
parameters={"spec": "extract_llm_api_spec 返回的 JSON 规格"},
|
||||
description="按提取的规格写入模型治理配置:供应商/端点/适配模板/模型。幂等。推荐 use_last_extract=true 取本会话锚定规格(禁止把规格复制进 spec——复制即编造);需改动加 overrides(仅 vendor_name/base_url/protocol/doc_url/doc_notes)。仅owner组织角色可用。",
|
||||
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",
|
||||
requires_confirmation=True,
|
||||
),
|
||||
ToolDefinition(
|
||||
name="apply_model_pricing",
|
||||
description="定价自动导入:按提取规格的 pricing 建定价方案(pricing_program+时序YAML)并挂模型 ppid。幂等(已有 ppid 拉链更新时序)。一个定价方案只服务一个模型;定价相同的模型共享同一 ppid。价格必须来自文档原文(每条带 doc_quote 否则拒绝)。仅owner组织角色可用。",
|
||||
parameters={"spec": "extract_llm_api_spec 返回的 JSON 规格(含 pricing 与 doc_url)"},
|
||||
description="定价自动导入:建定价方案(pricing_program+时序YAML)并挂模型 ppid。幂等(内容无变化跳过;同日原地更新;历史行才拉链——时序表始终只一条有效行)。一个定价方案只服务一个模型;定价相同的模型共享同一 ppid。推荐 use_last_extract=true 取本会话锚定规格(含 pricing/doc_url),禁止复制转述。仅owner组织角色可用。",
|
||||
parameters={
|
||||
"use_last_extract": "true=用本会话锚定规格的 pricing+doc_url(推荐)",
|
||||
"spec": "(仅无锚定时的兜底)extract 返回的 JSON 规格原文",
|
||||
},
|
||||
category="platform",
|
||||
requires_confirmation=True,
|
||||
),
|
||||
@ -1043,12 +1206,14 @@ PLATFORM_PROMPT = """
|
||||
## 模型自动配置全链工作流(用户给文档 URL 要求配置模型时,按序独立完成)
|
||||
1. **先 `load_skill` 加载 `model-auto-config`**(含媒体转换铁律/能力分类先行/定价新模式),严格执行
|
||||
2. `fetch_model_doc` 抓取官方文档(返回文本末尾带 [出处URL] 行)
|
||||
3. `extract_llm_api_spec` 通读文档提取规格:端点/协议/能力/sync_mode/async_steps,
|
||||
以及 **pricing(每条价格带 doc_quote 文档原文)和 doc_url(照抄出处行)**
|
||||
3. `extract_llm_api_spec` 通读文档提取规格——**成功后规格自动锚定到本会话缓存**
|
||||
(会话级隔离),返回里有 __anchored__ 提示
|
||||
4. 把提取摘要给用户确认(端点/能力/定价原文/出处),确认后:
|
||||
5. `apply_llm_config` 落库供应商/适配模板/模型(模型描述自动带 [出处:URL])
|
||||
6. `apply_model_pricing` 建定价方案并挂 ppid(定价描述带出处URL+文档原文;
|
||||
一个方案只服务一个模型,无 model 维度无 filters,维度平铺;幂等拉链更新)
|
||||
5. `apply_llm_config` 传 **{"use_last_extract": true}**(用户要求改名等只加
|
||||
overrides 白名单覆盖项,如 {"overrides": {"vendor_name": "阿里云百炼"}})。
|
||||
**绝对禁止把规格 JSON 复制/转述进 spec 参数——转述必编造结构(历史教训:
|
||||
三轮全编造 vendor{}/api_profile{} 等不存在的字段)。**
|
||||
6. `apply_model_pricing` 同样传 **{"use_last_extract": true}**(定价+出处已锚定)
|
||||
7. `test_model_call` 真实调用测试(前置:账号已配 api_key,没配则如实告知用户去补,
|
||||
不要伪造结果)——生成类模型传 params(image_file/resolution/duration 等)
|
||||
8. `check_model_accounting` 检查记账(传上一步的 task_ref):
|
||||
@ -1057,6 +1222,12 @@ PLATFORM_PROMPT = """
|
||||
- failed → 按 reason/hint 处置(未映射产品→告知用户在产品管理导入产线模型)
|
||||
9. 汇报:配置/定价/测试/记账四段结果 + 出处 URL + 遗留事项(如模板需人工核对项)
|
||||
|
||||
## 工具报错自救规则
|
||||
- 报「spec 结构不符/缺少 xxx」→ **不要改写结构重试**,改用 use_last_extract=true;
|
||||
本会话没锚定就重新走 fetch→extract(锚定是会话级的,跨会话不可见)
|
||||
- 报「能力分类未登记」→ 如实转告用户需先加能力分类(这是人工评审门,不要绕过)
|
||||
- 报「media_audit/runtime_note/需人工核对」警告 → 原样转告用户,不假装完成
|
||||
|
||||
## 硬规则
|
||||
- **定价只允许来自文档原文**(doc_quote 强制),禁止编造/换算/推导
|
||||
- **出处必留**:模型注册表描述字段与定价方案描述字段都必须保存文档 URL
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user