mirror of
https://github.com/val1813/kwcode.git
synced 2026-09-03 14:42:13 +08:00
feat: P1+P2 — auto task decomposition, pre-search, PCED-Lite
P1-A: Hard tasks auto-trigger TaskCompiler - Gate PROMPT extended with needs_search + subtask_hint (backward compat) - Planner.auto_decompose(): hint-based DAG generation (1 LLM call) - Graceful degradation: parse failure → single task (P1-RED-1) P1-B: Gate search decision front-loading - orchestrator.run() accepts pre_search_results parameter - Pre-search results injected into ctx before pipeline starts - Avoids 2 failed retries before searching P2: PCED-Lite (search/pced_lite.py) - Parallel independent inference per document (ThreadPoolExecutor) - Consistency voting for final answer (char-level overlap) - FLEX-2: degrades on VRAM<6GB or <3 documents - Based on arXiv:2601.08670 (PCED, 2026) 311 tests passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
26
CHANGELOG.md
26
CHANGELOG.md
@@ -4,6 +4,32 @@ All notable changes to KWCode are documented here.
|
||||
|
||||
---
|
||||
|
||||
## [1.0.5] - 2026-04-30
|
||||
|
||||
### P1+P2:自动任务拆分 + 预搜索 + PCED-Lite
|
||||
|
||||
**理论来源:**
|
||||
- ExpertRAG (2026):Gate层搜索决策前移,避免失败后才搜索的浪费
|
||||
- PCED (arXiv:2601.08670, 2026):并行上下文专家解码,180倍TTFT加速
|
||||
- Task Decomposition Research (2026):后台无感知任务分解是区分功能性agent的关键机制
|
||||
|
||||
### Added
|
||||
|
||||
- **Gate 输出扩展**(向后兼容):新增 `needs_search`(是否需要实时数据)和 `subtask_hint`(子任务提示)两个字段
|
||||
- **Planner.auto_decompose()**:基于 Gate 的 subtask_hint 自动拆分 hard 任务为 DAG
|
||||
- 只在 hint 有 2-5 个子任务时触发
|
||||
- LLM 一次调用确认依赖关系
|
||||
- 失败静默降级为单任务(P1-RED-1)
|
||||
- **预搜索**:Gate 判断 `needs_search=true` 时,在 orchestrator.run() 前预加载实时数据
|
||||
- orchestrator.run() 新增 `pre_search_results` 参数
|
||||
- 预搜索结果直接注入 ctx.search_results,跳过失败触发的搜索
|
||||
- **PCED-Lite** (`search/pced_lite.py`):
|
||||
- 对每个搜索结果独立生成答案(ThreadPoolExecutor 并行)
|
||||
- 一致性投票选最终答案(字符级重叠率判断)
|
||||
- FLEX-2:VRAM<6GB 或文档<3 时静默降级到 BM25 拼接
|
||||
|
||||
---
|
||||
|
||||
## [1.0.4] - 2026-04-30
|
||||
|
||||
### 代码审查:修复 8 个空架子/竞态/数据错误
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
| 日期 | 版本 | 更新内容 |
|
||||
|------|------|---------|
|
||||
| 04-30 | v1.0.5 | **P1+P2实现**:Gate新增needs_search/subtask_hint字段;hard任务自动拆分(Planner.auto_decompose);预搜索前移;PCED-Lite多源聚合(arXiv:2601.08670) |
|
||||
| 04-30 | v1.0.4 | **代码审查修复8项**:DebugSubagent实例化接入;PromptOptimizer接入投产流程;Checkpoint并行竞态修复;conversation_history存真实LLM输出 |
|
||||
| 04-30 | v1.0.3 | **三层上下文架构**:Active(摘要≤2K)+Structured State(Python对象精确传递)+Archive(文件BM25检索);代码块压缩保护(CTX-RED-1);paramiko持久SSH会话 |
|
||||
| 04-29 | v1.0.2 | **MoE框架补全**:Token预算管控(超限自动终止);Guardrails护栏(危险命令拦截+敏感文件备份);执行可观测性(结构化trace);会话连续性(SESSION.md) |
|
||||
|
||||
@@ -28,7 +28,16 @@ expert_type选项:
|
||||
- office:仅限生成Excel(.xlsx)/Word(.docx)/PPT(.pptx)办公文档,不包括代码文件
|
||||
- chat:问候、闲聊、非编码问题、询问天气、询问知识
|
||||
|
||||
difficulty选项:easy | hard(hard = 跨多文件/逻辑复杂/描述模糊)
|
||||
difficulty选项:easy | hard
|
||||
hard条件(满足任意一条):
|
||||
- 明确涉及多个不同类型的操作(如"找bug AND 生成网页 AND 搜索数据")
|
||||
- 涉及文件数估计 > 3个
|
||||
- 需要先获取外部信息才能生成代码
|
||||
|
||||
needs_search选项:true | false
|
||||
true条件:任务需要实时数据(天气/股价/当前时间/最新版本)或需要查询API文档/库文档
|
||||
|
||||
subtask_hint:仅difficulty=hard时填写,逗号分隔的子任务简述。不建议拆分时留空字符串。
|
||||
|
||||
关键区分规则:
|
||||
- office仅用于Excel/Word/PPT,代码文件(.py/.js/.html/.css/.json/.go/.ts/.sh)一律不选office
|
||||
@@ -38,20 +47,13 @@ difficulty选项:easy | hard(hard = 跨多文件/逻辑复杂/描述模糊
|
||||
- 不确定时优先选codegen或locator_repair,不要选office
|
||||
|
||||
示例:
|
||||
- "你好" → {{"expert_type": "chat", "task_summary": "问候", "difficulty": "easy"}}
|
||||
- "今天南京天气" → {{"expert_type": "chat", "task_summary": "问天气", "difficulty": "easy"}}
|
||||
- "帮我修复登录bug" → {{"expert_type": "locator_repair", "task_summary": "修复登录", "difficulty": "easy"}}
|
||||
- "修复src/parser.py中的IndexError" → {{"expert_type": "locator_repair", "task_summary": "修复越界", "difficulty": "easy"}}
|
||||
- "重构src/reports.py提取公共函数" → {{"expert_type": "refactor", "task_summary": "提取函数", "difficulty": "easy"}}
|
||||
- "写个排序函数" → {{"expert_type": "codegen", "task_summary": "排序函数", "difficulty": "easy"}}
|
||||
- "写一个Flask API" → {{"expert_type": "codegen", "task_summary": "Flask API", "difficulty": "easy"}}
|
||||
- "写一个app.py" → {{"expert_type": "codegen", "task_summary": "生成app", "difficulty": "easy"}}
|
||||
- "生成一个config.json" → {{"expert_type": "codegen", "task_summary": "生成配置", "difficulty": "easy"}}
|
||||
- "给这个函数写注释" → {{"expert_type": "doc", "task_summary": "写注释", "difficulty": "easy"}}
|
||||
- "修复src/app.py的import错误" → {{"expert_type": "locator_repair", "task_summary": "修复import", "difficulty": "easy"}}
|
||||
- "生成一个Excel报表" → {{"expert_type": "office", "task_summary": "Excel报表", "difficulty": "easy"}}
|
||||
- "你好" → {{"expert_type":"chat","task_summary":"问候","difficulty":"easy","needs_search":false,"subtask_hint":""}}
|
||||
- "帮我修复登录bug" → {{"expert_type":"locator_repair","task_summary":"修复登录","difficulty":"easy","needs_search":false,"subtask_hint":""}}
|
||||
- "搜索南京天气做网页" → {{"expert_type":"codegen","task_summary":"天气网页","difficulty":"hard","needs_search":true,"subtask_hint":"搜索天气数据,生成展示网页"}}
|
||||
- "重构认证模块" → {{"expert_type":"refactor","task_summary":"重构认证","difficulty":"hard","needs_search":false,"subtask_hint":""}}
|
||||
- "生成一个Excel报表" → {{"expert_type":"office","task_summary":"Excel报表","difficulty":"easy","needs_search":false,"subtask_hint":""}}
|
||||
|
||||
格式:{{"expert_type": "...", "task_summary": "10字内", "difficulty": "..."}}
|
||||
格式:{{"expert_type":"...","task_summary":"10字内","difficulty":"...","needs_search":false,"subtask_hint":""}}
|
||||
|
||||
用户输入:{user_input}"""
|
||||
|
||||
@@ -179,6 +181,8 @@ class Gate:
|
||||
"expert_type": et,
|
||||
"task_summary": summary[:20] if summary else user_input[:10],
|
||||
"difficulty": diff,
|
||||
"needs_search": bool(result.get("needs_search", False)),
|
||||
"subtask_hint": str(result.get("subtask_hint", "")),
|
||||
}
|
||||
except (json.JSONDecodeError, ValueError, KeyError, AttributeError, TypeError) as e:
|
||||
logger.warning("Gate parse failed (raw=%r): %s", raw[:200], e)
|
||||
|
||||
@@ -84,6 +84,7 @@ class PipelineOrchestrator:
|
||||
on_status=None,
|
||||
no_search: bool = False,
|
||||
skip_checkpoint: bool = False,
|
||||
pre_search_results: str = "",
|
||||
) -> dict:
|
||||
"""
|
||||
Execute the expert pipeline.
|
||||
@@ -105,6 +106,12 @@ class PipelineOrchestrator:
|
||||
|
||||
expert_type = gate_result.get("expert_type", "locator_repair")
|
||||
|
||||
# ── Pre-search results injection (P1-B) ──
|
||||
if pre_search_results:
|
||||
ctx.search_results = pre_search_results
|
||||
ctx.search_triggered = True
|
||||
self._emit(on_status, "search", "已预加载实时数据")
|
||||
|
||||
# ── KWCODE.md rules injection ──
|
||||
kwcode_sections = load_kwcode_md(project_root)
|
||||
if kwcode_sections:
|
||||
|
||||
@@ -71,9 +71,10 @@ def estimate_risk(
|
||||
|
||||
class Planner:
|
||||
|
||||
def __init__(self, locator, pattern_md_module):
|
||||
def __init__(self, locator, pattern_md_module, llm=None):
|
||||
self.locator = locator
|
||||
self.pattern_md = pattern_md_module
|
||||
self.llm = llm
|
||||
|
||||
def generate_plan(self, ctx: TaskContext) -> list[PlanStep]:
|
||||
"""Generate execution plan without modifying any files (P1-RED-2)."""
|
||||
@@ -239,3 +240,101 @@ class Planner:
|
||||
except Exception as e:
|
||||
logger.debug("[planner] preview failed: %s", e)
|
||||
return [], []
|
||||
|
||||
# ── P1-A: 自动任务拆分 ──
|
||||
|
||||
def auto_decompose(
|
||||
self,
|
||||
user_input: str,
|
||||
gate_result: dict,
|
||||
project_root: str,
|
||||
) -> "list[dict] | None":
|
||||
"""
|
||||
基于Gate的subtask_hint自动拆分任务。
|
||||
返回tasks列表或None(不适合拆分时)。
|
||||
P1-RED-1:失败降级None,调用方走单任务。
|
||||
P1-RED-5:LLM调用超时10s。
|
||||
"""
|
||||
if not self.llm:
|
||||
return None
|
||||
|
||||
subtask_hint = gate_result.get("subtask_hint", "").strip()
|
||||
|
||||
# 没有hint或hint为空:不拆分
|
||||
if not subtask_hint:
|
||||
return None
|
||||
|
||||
hints = [h.strip() for h in subtask_hint.split(",") if h.strip()]
|
||||
|
||||
# 只有一个子任务hint:不拆分
|
||||
if len(hints) < 2:
|
||||
return None
|
||||
|
||||
# 超过5个hint:可能是错误输出,不拆分
|
||||
if len(hints) > 5:
|
||||
logger.warning("[planner] subtask_hint过多(%d),跳过自动拆分", len(hints))
|
||||
return None
|
||||
|
||||
# 构建tasks(用LLM确认依赖关系)
|
||||
try:
|
||||
tasks = self._build_dag_from_hints(user_input, hints)
|
||||
return tasks if tasks else None
|
||||
except Exception as e:
|
||||
logger.warning("[planner] 自动拆分失败: %s,走单任务", e)
|
||||
return None # P1-RED-1
|
||||
|
||||
def _build_dag_from_hints(
|
||||
self,
|
||||
user_input: str,
|
||||
hints: list[str],
|
||||
) -> "list[dict] | None":
|
||||
"""用一次LLM调用把hints转成带依赖关系的DAG。"""
|
||||
import json as _json
|
||||
import re as _re
|
||||
|
||||
hint_list = "\n".join(f"- {h}" for h in hints)
|
||||
|
||||
prompt = f"""用户任务:{user_input}
|
||||
|
||||
需要完成的子任务(按顺序):
|
||||
{hint_list}
|
||||
|
||||
判断每个子任务是否依赖前面的子任务的输出结果。
|
||||
只有当后一个任务必须使用前一个任务的输出数据时,才标记为依赖。
|
||||
|
||||
输出JSON数组(严格格式,不要解释):
|
||||
[
|
||||
{{"id": "t1", "input": "子任务1描述", "depends_on": []}},
|
||||
{{"id": "t2", "input": "子任务2描述", "depends_on": ["t1"]}}
|
||||
]"""
|
||||
|
||||
raw = self.llm.generate(
|
||||
prompt=prompt,
|
||||
system="你是任务分析专家,只输出JSON,不输出其他内容。",
|
||||
max_tokens=300,
|
||||
temperature=0.0,
|
||||
)
|
||||
|
||||
# 解析JSON
|
||||
json_match = _re.search(r'\[.*?\]', raw, _re.DOTALL)
|
||||
if not json_match:
|
||||
return None
|
||||
|
||||
try:
|
||||
tasks = _json.loads(json_match.group())
|
||||
except _json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
# 验证格式
|
||||
if not isinstance(tasks, list) or len(tasks) < 2:
|
||||
return None
|
||||
|
||||
for t in tasks:
|
||||
if not isinstance(t, dict):
|
||||
return None
|
||||
if not all(k in t for k in ["id", "input", "depends_on"]):
|
||||
return None
|
||||
if not t.get("input", "").strip():
|
||||
return None
|
||||
|
||||
return tasks
|
||||
|
||||
173
kaiwu/search/pced_lite.py
Normal file
173
kaiwu/search/pced_lite.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
PCED-Lite:Parallel Context-of-Experts Decoding 的黑盒近似版。
|
||||
论文基础:arXiv:2601.08670 (PCED, 2026)
|
||||
|
||||
原版PCED需要访问模型内部logits(transformers库)。
|
||||
PCED-Lite适配Ollama黑盒API:
|
||||
1. 对每个搜索结果独立生成答案(并行)
|
||||
2. 用一致性投票选最终答案
|
||||
3. FLEX-2:VRAM不足或候选少于3时静默降级
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Optional
|
||||
|
||||
from kaiwu.llm.llama_backend import LLMBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PCED_SYSTEM = """你是信息提取专家。根据以下参考资料回答问题。
|
||||
只使用提供的资料,不要编造信息。如果资料不足以回答,直接说"资料不足"。
|
||||
回答简洁,不超过100字。"""
|
||||
|
||||
PCED_VOTER_SYSTEM = """你是信息综合专家。以下是多个来源对同一问题的回答。
|
||||
选出信息最可靠、最具体的回答,或综合多个一致的答案。
|
||||
直接输出最终答案,不要解释选择过程。"""
|
||||
|
||||
MAX_PARALLEL = 3
|
||||
TIMEOUT_PER_DOC = 8
|
||||
VOTE_TIMEOUT = 5
|
||||
|
||||
|
||||
def pced_lite_aggregate(
|
||||
query: str,
|
||||
documents: list[dict],
|
||||
llm: LLMBackend,
|
||||
vram_gb: float = 0,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
PCED-Lite主入口。
|
||||
返回聚合后的答案字符串,降级时返回None(调用方回退到拼接模式)。
|
||||
"""
|
||||
# FLEX-2:VRAM不足或候选太少时降级
|
||||
if vram_gb > 0 and vram_gb < 6:
|
||||
logger.info("[pced_lite] VRAM %.1fGB < 6GB,降级(FLEX-2)", vram_gb)
|
||||
return None
|
||||
|
||||
valid_docs = [d for d in documents if d.get("content") or d.get("snippet")]
|
||||
if len(valid_docs) < 3:
|
||||
logger.info("[pced_lite] 有效文档 %d < 3,降级", len(valid_docs))
|
||||
return None
|
||||
|
||||
docs_to_use = valid_docs[:MAX_PARALLEL]
|
||||
|
||||
# Step 1:并行对每个文档独立推理
|
||||
candidate_answers = _parallel_inference(query, docs_to_use, llm)
|
||||
|
||||
if not candidate_answers:
|
||||
return None
|
||||
|
||||
if len(candidate_answers) == 1:
|
||||
return candidate_answers[0]
|
||||
|
||||
# Step 2:投票选最一致的答案
|
||||
final_answer = _vote(query, candidate_answers, llm)
|
||||
return final_answer
|
||||
|
||||
|
||||
def _parallel_inference(
|
||||
query: str,
|
||||
documents: list[dict],
|
||||
llm: LLMBackend,
|
||||
) -> list[str]:
|
||||
"""对每个文档独立推理,返回候选答案列表。"""
|
||||
|
||||
def _infer_one(doc: dict) -> Optional[str]:
|
||||
content = doc.get("content") or doc.get("snippet", "")
|
||||
if not content:
|
||||
return None
|
||||
|
||||
prompt = f"""参考资料:
|
||||
{content[:1500]}
|
||||
|
||||
问题:{query}"""
|
||||
|
||||
try:
|
||||
answer = llm.generate(
|
||||
prompt=prompt,
|
||||
system=PCED_SYSTEM,
|
||||
max_tokens=150,
|
||||
temperature=0.0,
|
||||
)
|
||||
if answer and "资料不足" not in answer:
|
||||
return answer.strip()
|
||||
except Exception as e:
|
||||
logger.debug("[pced_lite] 单文档推理失败: %s", e)
|
||||
return None
|
||||
|
||||
answers = []
|
||||
with ThreadPoolExecutor(max_workers=MAX_PARALLEL) as executor:
|
||||
futures = {executor.submit(_infer_one, doc): doc for doc in documents}
|
||||
for future in as_completed(futures, timeout=TIMEOUT_PER_DOC + 2):
|
||||
try:
|
||||
result = future.result()
|
||||
if result:
|
||||
answers.append(result)
|
||||
except Exception as e:
|
||||
logger.debug("[pced_lite] future失败: %s", e)
|
||||
|
||||
logger.info("[pced_lite] 并行推理完成: %d/%d 个候选", len(answers), len(documents))
|
||||
return answers
|
||||
|
||||
|
||||
def _vote(query: str, candidates: list[str], llm: LLMBackend) -> str:
|
||||
"""投票选最一致的答案。"""
|
||||
# 如果答案都很相似,直接用第一个
|
||||
if _all_similar(candidates):
|
||||
logger.info("[pced_lite] 候选答案高度一致,直接使用第一个")
|
||||
return candidates[0]
|
||||
|
||||
# 否则让LLM综合
|
||||
candidates_text = "\n\n".join(
|
||||
f"来源{i+1}:{c}" for i, c in enumerate(candidates)
|
||||
)
|
||||
prompt = f"""问题:{query}
|
||||
|
||||
多个来源的回答:
|
||||
{candidates_text}
|
||||
|
||||
请选择或综合最可靠的答案:"""
|
||||
|
||||
try:
|
||||
final = llm.generate(
|
||||
prompt=prompt,
|
||||
system=PCED_VOTER_SYSTEM,
|
||||
max_tokens=150,
|
||||
temperature=0.0,
|
||||
)
|
||||
return final.strip() if final else candidates[0]
|
||||
except Exception:
|
||||
return candidates[0]
|
||||
|
||||
|
||||
def _all_similar(candidates: list[str], threshold: float = 0.5) -> bool:
|
||||
"""简单判断候选答案是否高度一致(字符级重叠率)。"""
|
||||
if len(candidates) < 2:
|
||||
return True
|
||||
|
||||
def char_set(text: str) -> set:
|
||||
"""提取所有有意义的字符(中文单字+英文单词+数字)。"""
|
||||
chars = set()
|
||||
# 中文单字
|
||||
for c in text:
|
||||
if '\u4e00' <= c <= '\u9fff':
|
||||
chars.add(c)
|
||||
# 英文单词和数字
|
||||
for w in re.findall(r'[a-z]+|\d+', text.lower()):
|
||||
chars.add(w)
|
||||
return chars
|
||||
|
||||
base = char_set(candidates[0])
|
||||
if not base:
|
||||
return False
|
||||
|
||||
for c in candidates[1:]:
|
||||
other = char_set(c)
|
||||
if not other:
|
||||
continue
|
||||
overlap = len(base & other) / len(base | other)
|
||||
if overlap < threshold:
|
||||
return False
|
||||
return True
|
||||
Reference in New Issue
Block a user