From 2c136c1fde017efc48cf1b0e07304e83ce40e27d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Fri, 29 May 2026 11:31:31 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8F=82=E8=80=83=E6=96=87=E7=8C=AE=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E5=90=88=E5=B9=B6=E4=B8=BA=E5=8D=95=E4=B8=80=20refere?= =?UTF-8?q?nceBatch=20=E5=B9=B6=E7=BB=9F=E4=B8=80=E8=BF=9E=E7=BB=AD?= =?UTF-8?q?=E7=BC=96=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将同一轮检索结果合并为一个 referenceBatch,由系统统一从 [1] 连续编号,避免每个检索词各自重新编号;提示词引导优先并行检索后再作答。智能体列表接口并发加载技能等轻量信息以渲染卡片徽章,并新增参考文献相关测试。 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../backend/app/gateway/routers/agents.py | 21 ++-- .../backend/app/gateway/services.py | 3 +- .../deerflow/agents/lead_agent/prompt.py | 34 +++---- .../middlewares/rag_citation_middleware.py | 6 +- .../harness/deerflow/runtime/references.py | 39 ++++++-- .../harness/tests/test_rag_references.py | 96 +++++++++++++++++++ 6 files changed, 164 insertions(+), 35 deletions(-) create mode 100644 offline-backend-20260512/backend/packages/harness/tests/test_rag_references.py diff --git a/offline-backend-20260512/backend/app/gateway/routers/agents.py b/offline-backend-20260512/backend/app/gateway/routers/agents.py index 512dcb7a..4b0f50fb 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/agents.py +++ b/offline-backend-20260512/backend/app/gateway/routers/agents.py @@ -435,14 +435,19 @@ async def list_agents( agents = await store.list_all() else: agents = await store.list_visible(user_id) - # List endpoint stays lean: skip per-agent SOUL.md read, config.yaml - # parse, and dir self-heal — none of those feed UI fields the gallery / - # recommend dialog / selector actually render. Detail endpoints (single - # agent + create/update responses) still load them. - responses = [ - _record_to_response(a, include_soul=False, include_extras=False) - for a in agents - ] + # Load lightweight extras (skills / model / tool_groups from config.yaml) + # so the gallery cards can render skill badges. SOUL.md is still skipped + # (only the edit dialog reads it). The per-agent config.yaml reads run in + # worker threads concurrently, so the event loop stays responsive even + # with many agents. + responses = list( + await asyncio.gather( + *( + _record_to_response_async(a, include_soul=False, include_extras=True) + for a in agents + ) + ) + ) assignments = await tag_store.list_assignments("agent", [r.id for r in responses]) favorite_ids = await store.list_favorite_ids(user_id) diff --git a/offline-backend-20260512/backend/app/gateway/services.py b/offline-backend-20260512/backend/app/gateway/services.py index 46965a2c..873e0299 100644 --- a/offline-backend-20260512/backend/app/gateway/services.py +++ b/offline-backend-20260512/backend/app/gateway/services.py @@ -104,7 +104,8 @@ _BUILTIN_GRAPH_IDS: frozenset[str] = frozenset({"lead_agent", "canvas_agent", "a _RAG_CITATION_PROMPT = """当前对话已开启参考文献模式。 如果本轮调用了搜索、知识库、检索类技能或工具,请基于本轮所有检索结果整体回答。 -你必须把同一轮内多次检索得到的资料视为同一批参考文献,按出现顺序从 [1] 开始连续编号,不要让每个检索词各自从 [1] 重新编号。 +如果你已经知道需要多个检索词,请优先在同一步里并行调用检索工具,等这些检索都返回后再回答,不要查一个检索词就先写一版答案。 +你必须把同一轮内多次检索得到的资料视为同一个 referenceBatch,按系统稍后提供的合并批次从 [1] 开始连续编号,不要让每个检索词各自从 [1] 重新编号。 回答正文中的关键结论必须标注对应编号,例如 [1]、[2],多个来源可写为 [1][3][5]。 不要引用不存在的编号,不要使用技能内部自带的编号,不要把参考文献完整列表堆在正文末尾。 如果没有任何可用检索结果,请直接说明未检索到可靠参考资料。""" diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py index 96dacf1f..366c3ea8 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py @@ -703,12 +703,14 @@ def _get_cached_skills_prompt_section( display_pattern = ( """ -**Skill Result Display Pattern:** -- If a skill has ``, use the skill's returned result order as reference numbers. Cite facts from the first result as `[[1]]`, the second as `[[2]]`, and so on. Use only numbers that correspond to returned results. -- If a skill has ``, summarize the returned items as a concise list and keep item order stable. -- If a skill has ``, summarize the result and output `` where the frontend should render the real table. Do not hand-write the table rows yourself. -- If a skill has ``, summarize the result and output `` where the frontend should render the cards. -- If a skill has ``, follow the custom display instruction in the `` tag when presenting the skill result. +**Skill Result Display Pattern:** +- If RAG/reference display mode is enabled, do not number each skill result independently. Treat all retrieval results from the same user turn as one combined `referenceBatch` and cite with `[1]`, `[2]`, etc. from the system-provided reference prompt. +- In RAG/reference display mode, do not output ``; the frontend renders cards, tables, lists, and citation details in the right reference panel. +- If RAG/reference display mode is not enabled and a skill has ``, use the skill's returned result order as reference numbers. Cite facts from the first result as `[1]`, the second as `[2]`, and so on. Use only numbers that correspond to returned results. +- If a skill has ``, summarize the returned items as a concise list and keep item order stable. +- If a skill has ``, summarize the result and output `` where the frontend should render the real table. Do not hand-write the table rows yourself. +- If a skill has ``, summarize the result and output `` where the frontend should render the cards. +- If a skill has ``, follow the custom display instruction in the `` tag when presenting the skill result. """ if display_signature else "" @@ -993,16 +995,16 @@ Writing mode is enabled for this run. network_policy_section = _build_network_policy_section() rag_mode_section = ( """ - -RAG/reference display mode is enabled. -When a search, knowledge, skill, or MCP tool returns a result list: -- If the skill has a display adapter, follow that adapter first. -- For `citation`, cite returned items with `[[1]]`, `[[2]]`, etc. Only cite real returned items. -- For `table`, do not hand-write the table. Summarize and place `` where the table should appear. The frontend will render the real tool result as a stable table. -- For `list` or `card`, summarize and place `` or `` where the structured result should appear. -- If no display adapter exists, default to citation mode with numbered citations. - -""" + +RAG/reference display mode is enabled. +When a search, knowledge, skill, or MCP tool returns a result list: +- If multiple search terms are needed, prefer calling the retrieval tools for all known terms in the same step, then answer after the tool results are returned. +- Treat every retrieval result from the same user turn as one combined `referenceBatch`; do not restart numbering for each search term, tool call, skill, or display adapter. +- Use only the system-provided merged reference numbers in the final answer, formatted as `[1]`, `[2]`, `[1][3][5]`. +- Do not output `` in RAG/reference mode. The frontend renders configured cards, tables, lists, and citation details in the right reference panel. +- If no display adapter exists, the frontend defaults the right reference panel to numbered citations. + +""" if rag_mode_enabled else "" ) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/rag_citation_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/rag_citation_middleware.py index 04eae320..07883766 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/rag_citation_middleware.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/rag_citation_middleware.py @@ -80,13 +80,15 @@ class RagCitationMiddleware(AgentMiddleware[AgentState]): return None summary = build_reference_debug_summary(messages, [batch] if batch else None) _log_rag_debug( - "middleware reference-prompt enabled=true prompt=true messages=%s tool_messages=%s " - "raw_items=%s normalized_items=%s kept_items=%s latest_batch_sources=%s", + "middleware reference-prompt enabled=true prompt=true single_batch=true messages=%s " + "tool_messages=%s raw_items=%s normalized_items=%s kept_items=%s latest_batch_id=%s " + "latest_batch_sources=%s", summary["messages"], summary["tool_messages"], summary["raw_items"], summary["normalized_items"], summary["kept_items"], + (batch or {}).get("id"), len((batch or {}).get("sources") or []), ) _log_rag_debug( diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/references.py b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/references.py index d6029b09..e04d8f36 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/runtime/references.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/runtime/references.py @@ -37,24 +37,45 @@ def build_reference_batches(messages: list[Any]) -> list[dict[str, Any]]: return batches -def build_latest_reference_prompt(messages: list[Any]) -> tuple[str, dict[str, Any] | None]: +def build_latest_reference_batch(messages: list[Any]) -> dict[str, Any] | None: + """Return the single combined citation batch for the latest human turn.""" human_index = _find_latest_human_index(messages) if human_index is None: - return "", None + return None sources = _collect_sources(messages[human_index + 1 :]) if not sources: - return "", None + return None assistant = _find_reference_batch_anchor(messages, human_index + 1, len(messages)) message_id = _message_id(assistant) or _message_id(messages[human_index]) or str(human_index) - batch = { + return { "id": message_id, "assistant_message_id": message_id, + "source_count": len(sources), "sources": sources, } + + +def build_latest_reference_prompt(messages: list[Any]) -> tuple[str, dict[str, Any] | None]: + batch = build_latest_reference_batch(messages) + if batch is None: + return "", None + return build_reference_prompt_for_batch(batch), batch + + +def build_reference_prompt_for_batch(batch: dict[str, Any]) -> str: + """Build the authoritative model prompt from one already-combined batch.""" + sources = [ + source + for source in batch.get("sources", []) + if isinstance(source, dict) + ] + source_count = len(sources) lines = [ - "本轮检索结果已由系统统一合并并重新编号。最终回答必须使用下面这套编号。", - "如果你仍需要补充检索,请先继续调用工具;最终回答只能引用最新合并后的编号。", + f"本轮所有检索结果已合并为同一个 referenceBatch,共 {source_count} 条,编号从 [1] 到 [{source_count}] 唯一连续。", + "下面这一批就是当前回答唯一可用的参考文献批次;不要按检索词、工具调用次数或工具原始编号重新分批编号。", + "如果你仍需要补充检索,请只继续调用工具,不要输出正文;最终回答只能引用补充检索后系统重新合并出的最新 referenceBatch。", "", + f"", ] for source in sources: title = source.get("title") or f"参考文献 {source.get('index')}" @@ -73,18 +94,20 @@ def build_latest_reference_prompt(messages: list[Any]) -> tuple[str, dict[str, A if content: lines.append(content) lines.append("") + lines.append("") + lines.append("") lines.extend( [ "引用规则:", "1. 回答正文中的关键结论必须标注对应编号,例如 [1]、[2]。", "2. 多个来源可写为 [1][3][5]。", "3. 只能引用上方存在的编号,不要编造编号。", - "4. 不要使用工具原始编号,也不要重新排序编号。", + "4. 不要使用工具原始编号,也不要重新排序、重新分批或重新从 [1] 开始编号。", "5. 不要在正文末尾堆完整参考文献列表,前端会展示右侧参考文献。", "6. 参考文献模式下不要输出 占位符,也不要在正文里额外渲染卡片、表格或列表;前端会在右侧参考文献区域展示。", ] ) - return "\n".join(lines).strip(), batch + return "\n".join(lines).strip() def _find_latest_human_index(messages: list[Any]) -> int | None: diff --git a/offline-backend-20260512/backend/packages/harness/tests/test_rag_references.py b/offline-backend-20260512/backend/packages/harness/tests/test_rag_references.py new file mode 100644 index 00000000..c28514d5 --- /dev/null +++ b/offline-backend-20260512/backend/packages/harness/tests/test_rag_references.py @@ -0,0 +1,96 @@ +"""Tests for RAG citation batch construction.""" + +from __future__ import annotations + +import json + +from deerflow.runtime.references import ( + build_latest_reference_batch, + build_latest_reference_prompt, + build_reference_batches, +) + + +def _tool_message(call_id: str, query: str, start: int) -> dict: + return { + "type": "tool", + "name": "web_search", + "tool_call_id": call_id, + "content": json.dumps( + { + "query": query, + "results": [ + { + "title": f"{query} result {start}", + "content": f"{query} content {start}", + "url": f"https://example.test/{start}", + }, + { + "title": f"{query} result {start + 1}", + "content": f"{query} content {start + 1}", + "url": f"https://example.test/{start + 1}", + }, + ], + }, + ensure_ascii=False, + ), + } + + +def test_latest_reference_prompt_uses_one_combined_batch_for_multi_search_turn() -> None: + messages = [ + {"type": "human", "id": "human-1", "content": "search two terms"}, + { + "type": "ai", + "id": "ai-search-1", + "content": "", + "tool_calls": [{"id": "call-1", "name": "web_search", "args": {"query": "Trump"}}], + }, + _tool_message("call-1", "Trump", 1), + { + "type": "ai", + "id": "ai-search-2", + "content": "", + "tool_calls": [{"id": "call-2", "name": "web_search", "args": {"query": "White House"}}], + }, + _tool_message("call-2", "White House", 3), + ] + + batch = build_latest_reference_batch(messages) + assert batch is not None + assert batch["source_count"] == 4 + assert [source["index"] for source in batch["sources"]] == [1, 2, 3, 4] + assert [source["title"] for source in batch["sources"]] == [ + "Trump result 1", + "Trump result 2", + "White House result 3", + "White House result 4", + ] + + prompt, prompt_batch = build_latest_reference_prompt(messages) + assert prompt_batch == batch + assert '' in prompt + assert "[1] Trump result 1" in prompt + assert "[4] White House result 4" in prompt + assert "重新分批" in prompt + + +def test_reference_batches_keep_separate_user_turns_but_merge_searches_inside_turn() -> None: + messages = [ + {"type": "human", "id": "human-1", "content": "first"}, + {"type": "ai", "id": "ai-1", "content": "", "tool_calls": [{"id": "c1", "name": "web_search"}]}, + _tool_message("c1", "first", 1), + {"type": "ai", "id": "answer-1", "content": "answer [1]"}, + {"type": "human", "id": "human-2", "content": "second"}, + {"type": "ai", "id": "ai-2", "content": "", "tool_calls": [{"id": "c2", "name": "web_search"}]}, + _tool_message("c2", "second-a", 3), + {"type": "ai", "id": "ai-3", "content": "", "tool_calls": [{"id": "c3", "name": "web_search"}]}, + _tool_message("c3", "second-b", 5), + {"type": "ai", "id": "answer-2", "content": "answer [1][4]"}, + ] + + batches = build_reference_batches(messages) + assert [len(batch["sources"]) for batch in batches] == [2, 4] + assert [source["index"] for source in batches[1]["sources"]] == [1, 2, 3, 4] + assert batches[0]["assistant_message_id"] == "answer-1" + assert batches[1]["assistant_message_id"] == "answer-2"