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/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 9b82ea58..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( @@ -152,28 +167,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 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