From ebba3e169290a6d014401ca88d04c4f20ced3579 Mon Sep 17 00:00:00 2001 From: ubuntu Date: Sat, 18 Jul 2026 10:25:21 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=E7=A7=8D=E5=AD=90=E5=8C=B9=E9=85=8D=E7=BC=BA=E5=8F=A3?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E8=AE=A9=E9=98=B6=E6=AE=B5=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E8=AF=8D=E5=AF=B9=20prompt=20=E5=89=8D=E7=BC=80=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E5=8F=8B=E5=A5=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit seed.py:管理员账号查找同时匹配 id=="admin" 或 username=="admin", 此前只按 id 匹配会在"id 不是 admin 但用户名是 admin"的场景插入 重复行、撞 (tenant_id, username) 唯一约束。 stage_protocol.py:render_stage_user_message 把静态的阶段规则/ 输出约束放到易变的时间戳/用户输入之前,避免每次调用都变化的内容 出现在 prompt 前部导致 DeepSeek 等 provider 的前缀缓存完全失效。 Co-Authored-By: Claude Sonnet 5 --- backend/app/db/seed.py | 9 +++++++- backend/app/llm/stage_protocol.py | 38 +++++++++++++++++++------------ backend/tests/test_llm_client.py | 16 ++++++++----- 3 files changed, 41 insertions(+), 22 deletions(-) diff --git a/backend/app/db/seed.py b/backend/app/db/seed.py index 12bcba6f..58ec21bc 100644 --- a/backend/app/db/seed.py +++ b/backend/app/db/seed.py @@ -3,6 +3,7 @@ import sys from pathlib import Path +from sqlalchemy import or_ from sqlmodel import Session, select from app import paths @@ -856,8 +857,14 @@ def seed_demo_data(session: Session) -> None: ) # 桌面/单机版默认管理员账号(admin / admin)。权限只读取数据库 role 字段。 + # 用 id=="admin" 或 username=="admin" 匹配:管理员可能被改了用户名(id 仍是 + # admin)也可能种子时 id 就不是 admin(username 仍是 admin)——只认一边会在 + # 另一边插入重复行,撞 id 主键或 (tenant_id, username) 唯一约束。 admin_user = session.exec( - select(User).where(User.tenant_id == "tenant_demo", User.username == "admin") + select(User).where( + User.tenant_id == "tenant_demo", + or_(User.id == "admin", User.username == "admin"), + ) ).first() if not admin_user: session.add( diff --git a/backend/app/llm/stage_protocol.py b/backend/app/llm/stage_protocol.py index 9b82ea58..f86d5f61 100644 --- a/backend/app/llm/stage_protocol.py +++ b/backend/app/llm/stage_protocol.py @@ -152,28 +152,36 @@ def render_stage_user_message( output_contract = json.dumps( output_contract or {}, ensure_ascii=False, separators=(",", ":") ) - sections = [] + # Static/highly-reused content goes first, volatile content last. Providers + # (DeepSeek, OpenAI-compatible proxies, etc.) cache the longest matching + # prefix of a request; a per-call timestamp or the user's raw message + # placed ahead of the large, stable stage instructions (e.g. a skill's + # full SKILL.md) breaks the prefix at the first byte and defeats caching + # for everything that follows, even though that content is identical + # across calls. Keeping the actual user question last also matches + # standard prompting practice (closest to where generation begins). + sections = [ + f"当前阶段:\n{stage.get('phase') or '未指定'}", + ( + "思考要求:\n保留完成当前阶段所需的简短思考;不要复述上下文、逐字段展开检查、" + "罗列无关备选方案或反复验证已明确的信息。得到可靠结论后立即按输出约束作答。" + ), + f"阶段规则:\n{str(stage.get('instructions') or '').strip()}", + f"输出约束:\n{output_contract}", + ] + if include_turn_header: + sections.append(f"用户记忆:\n{stage.get('memory') or '无'}") + sections.append( + "当前阶段独有内容:\n" + + json.dumps(projected, ensure_ascii=False, separators=(",", ":")) + ) if include_turn_header: sections.extend( [ - f"用户记忆:\n{stage.get('memory') or '无'}", f"本轮时间:\n{stage.get('turn_time') or '未提供'}", f"本轮用户输入:\n{user_message or '(空)'}", ] ) - sections.extend( - [ - f"当前阶段:\n{stage.get('phase') or '未指定'}", - ( - "思考要求:\n保留完成当前阶段所需的简短思考;不要复述上下文、逐字段展开检查、" - "罗列无关备选方案或反复验证已明确的信息。得到可靠结论后立即按输出约束作答。" - ), - f"阶段规则:\n{str(stage.get('instructions') or '').strip()}", - "当前阶段独有内容:\n" - + json.dumps(projected, ensure_ascii=False, separators=(",", ":")), - f"输出约束:\n{output_contract}", - ] - ) return "\n\n".join(sections) diff --git a/backend/tests/test_llm_client.py b/backend/tests/test_llm_client.py index cb718d0e..c9d5ed87 100644 --- a/backend/tests/test_llm_client.py +++ b/backend/tests/test_llm_client.py @@ -458,7 +458,7 @@ def test_generate_text_projects_conversation_context_messages(): assert '"pending_tasks"' not in current_input["content"] -def test_stage_input_uses_stable_history_and_puts_memory_time_and_question_first(): +def test_stage_input_uses_stable_history_and_puts_instructions_before_volatile_content(): client = object.__new__(LLMClient) client.client = _FakeOpenAIClient() client.model = "demo-model" @@ -492,13 +492,17 @@ def test_stage_input_uses_stable_history_and_puts_memory_time_and_question_first {"role": "assistant", "content": "请说明本次需求"}, ] current = messages[-1]["content"] - assert current.startswith("用户记忆:\n- 用户偏好简洁回复\n\n本轮时间:") - assert "本轮时间:\n2026-07-13T20:30:00+08:00" in current - assert "本轮用户输入:\n我想申请报销" in current - assert "当前阶段:\nRouter" in current + # Static/stable content (phase, thinking requirement, stage instructions) must + # come first so provider-side prompt-prefix caching isn't broken by a per-call + # timestamp or the user's raw message; volatile content stays last. + assert current.startswith("当前阶段:\nRouter") assert "思考要求:" in current assert "保留完成当前阶段所需的简短思考" in current - assert "available_skills" in current + assert current.index("只根据技能摘要路由。") < current.index("本轮时间:") + assert current.index("available_skills") < current.index("本轮用户输入:") + assert "本轮时间:\n2026-07-13T20:30:00+08:00" in current + assert "本轮用户输入:\n我想申请报销" in current + assert current.rstrip().endswith("本轮用户输入:\n我想申请报销") assert "memory_internal" not in current assert sum("我想申请报销" in str(message["content"]) for message in messages) == 1 From c7c830b6374741e3e24d7fc0b433b1a631c61146 Mon Sep 17 00:00:00 2001 From: ubuntu Date: Sat, 18 Jul 2026 11:15:23 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E8=AE=A9=E9=80=9A=E7=94=A8=E6=8A=80?= =?UTF-8?q?=E8=83=BD=E7=9A=84=20SKILL.md=20=E8=B5=B0=20system=20prompt=20?= =?UTF-8?q?=E7=BC=93=E5=AD=98=EF=BC=8C=E8=80=8C=E4=B8=8D=E6=98=AF=E6=AF=8F?= =?UTF-8?q?=E8=BD=AE=E9=87=8D=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan/Repair/Review 三个会用到 SKILL.md 全文的阶段,之前把全文塞进 每轮都变化的 stage payload 尾部,导致同一个技能被连续调用时,DeepSeek/ Gemini 等 provider 的前缀缓存完全命中不到这部分内容,每轮都要重新计费。 改成通过 unified_system_prompt(skill_markdown) 把全文放进 system message(对同一技能的 Plan/Repair/Review 调用保持字节级一致), payload 里不再重复携带。Selector/Reply 两个阶段不需要全文,保持不变。 顺带修掉 _skill_package_payload 里 SKILL.md 被重复发送两遍的问题 (markdown 字段 + package.files 里的 content_preview 各发一份相同内容)。 Co-Authored-By: Claude Sonnet 5 --- backend/app/general_skills/runner.py | 25 ++++++++++++++++------ backend/app/llm/stage_protocol.py | 19 ++++++++++++++-- backend/tests/test_unified_agent_prompt.py | 19 +++++++++++++--- 3 files changed, 52 insertions(+), 11 deletions(-) diff --git a/backend/app/general_skills/runner.py b/backend/app/general_skills/runner.py index f641bbc7..6044b752 100644 --- a/backend/app/general_skills/runner.py +++ b/backend/app/general_skills/runner.py @@ -295,7 +295,6 @@ def _generate_plan( "name": skill.name, "description": skill.description, "homepage": skill.homepage, - "markdown": skill.skill_markdown, "package": _skill_package_payload(skill), }, "runtime": { @@ -321,7 +320,7 @@ def _generate_plan( ) with llm_operation("general_skill.plan"): raw = LLMClient(_with_min_tokens(model_config, GENERAL_SKILL_MAX_TOKENS)).generate_json( - unified_system_prompt(), + unified_system_prompt(skill.skill_markdown), payload, ) plan = GeneralSkillExecutionPlan.model_validate(raw) @@ -455,7 +454,6 @@ def _repair_plan( "name": skill.name, "description": skill.description, "homepage": skill.homepage, - "markdown": skill.skill_markdown, "package": _skill_package_payload(skill), }, "runtime": { @@ -482,7 +480,7 @@ def _repair_plan( ) with llm_operation("general_skill.repair", attempt=next_attempt): raw = LLMClient(_with_min_tokens(model_config, GENERAL_SKILL_MAX_TOKENS)).generate_json( - unified_system_prompt(), + unified_system_prompt(skill.skill_markdown), payload, ) plan = GeneralSkillExecutionPlan.model_validate(raw) @@ -727,7 +725,6 @@ def _review_execution_result( "name": skill.name, "description": skill.description, "homepage": skill.homepage, - "markdown": _truncate(skill.skill_markdown, 6000), "package": _skill_package_payload(skill, preview_limit=6000), }, "runner": { @@ -752,7 +749,7 @@ def _review_execution_result( try: with llm_operation("general_skill.review", attempt=attempt): raw = LLMClient(model_config).generate_json( - unified_system_prompt(), payload + unified_system_prompt(skill.skill_markdown), payload ) review = GeneralSkillExecutionReview.model_validate(raw).model_dump(mode="json") except Exception as exc: @@ -822,11 +819,27 @@ def _skill_files(skill: GeneralSkill) -> list[dict[str, Any]]: def _skill_package_payload(skill: GeneralSkill, preview_limit: int = 12000) -> dict[str, Any]: + # SKILL.md's content is already sent in full via the sibling "markdown" + # field on every call site that includes a package payload; echoing it + # again here as a content_preview doubles the token cost of every plan/ + # repair/review call for no benefit, since the model already has it. + markdown = str(getattr(skill, "skill_markdown", "") or "") files = _skill_files(skill) previews: list[dict[str, Any]] = [] remaining = preview_limit for file in files: content = str(file.get("content") or "") + if file.get("path") == "SKILL.md" and content == markdown: + previews.append( + { + "path": file["path"], + "size": file.get("size"), + "mime_type": file.get("mime_type"), + "content_preview": "(与上方 skill.markdown 字段内容相同,不重复展开)", + "truncated": False, + } + ) + continue preview = content[: max(0, min(len(content), remaining))] remaining -= len(preview) previews.append( diff --git a/backend/app/llm/stage_protocol.py b/backend/app/llm/stage_protocol.py index f86d5f61..79bdeb52 100644 --- a/backend/app/llm/stage_protocol.py +++ b/backend/app/llm/stage_protocol.py @@ -90,8 +90,23 @@ } -def unified_system_prompt() -> str: - return UNIFIED_PROMPT_PATH.read_text(encoding="utf-8").strip() +def unified_system_prompt(skill_markdown: str | None = None) -> str: + base = UNIFIED_PROMPT_PATH.read_text(encoding="utf-8").strip() + markdown = (skill_markdown or "").strip() + if not markdown: + return base + # Appending the active general skill's SKILL.md here (rather than in the + # per-turn stage payload) puts it in the one message position that stays + # byte-identical across every call for this skill, so provider prefix + # caching (DeepSeek/Gemini) actually reuses it instead of re-billing the + # full text on every turn. See render_stage_user_message for the + # matching comment on the per-turn side. + return ( + base + + "\n\n---\n\n" + + "以下是当前技能的 SKILL.md 全文,是本次调用的固定背景资料:\n\n" + + markdown + ) def stage_payload( diff --git a/backend/tests/test_unified_agent_prompt.py b/backend/tests/test_unified_agent_prompt.py index 9ae51d8f..bd91871a 100644 --- a/backend/tests/test_unified_agent_prompt.py +++ b/backend/tests/test_unified_agent_prompt.py @@ -105,7 +105,7 @@ def fake_generate_text(self, system_prompt, payload): # noqa: ANN001 assert payload["_agent_stage"]["output_contract"] -def test_general_skill_substages_use_the_same_unified_system_prompt(monkeypatch) -> None: +def test_general_skill_substages_share_base_prompt_with_skill_caching_suffix(monkeypatch) -> None: systems: dict[str, str] = {} def fake_init(self, model_config): # noqa: ANN001 @@ -199,5 +199,18 @@ def fake_execute_plan( # noqa: ANN001 "Reflection / General Skill Review", "Response Generator / General Skill Reply", } - assert len(set(systems.values())) == 1 - assert "统一执行引擎" in next(iter(systems.values())) + base_prompt = systems["Router / General Skill Selector"] + assert "统一执行引擎" in base_prompt + # Selector and Reply never see the full SKILL.md, so their system + # message stays on the pure base prompt regardless of which skill is + # active — that keeps it maximally cache-stable across skills. + assert systems["Response Generator / General Skill Reply"] == base_prompt + # Plan and Review embed the active skill's SKILL.md in the system + # prompt (not the per-turn payload) so repeat calls for the same skill + # hit the provider's prefix cache instead of re-billing the full text + # on every turn. + skill_aware_prompt = systems["Step Agent / General Skill Plan"] + assert systems["Reflection / General Skill Review"] == skill_aware_prompt + assert skill_aware_prompt.startswith(base_prompt) + assert skill.skill_markdown in skill_aware_prompt + assert skill_aware_prompt != base_prompt