|
|
@@ -13,14 +13,19 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
|
|
import org.springframework.stereotype.Component;
|
|
|
|
|
|
import java.io.BufferedReader;
|
|
|
+import java.io.IOException;
|
|
|
import java.io.InputStreamReader;
|
|
|
import java.net.HttpURLConnection;
|
|
|
import java.net.URI;
|
|
|
import java.nio.charset.StandardCharsets;
|
|
|
+import java.nio.file.Files;
|
|
|
+import java.nio.file.Path;
|
|
|
+import java.nio.file.Paths;
|
|
|
import java.util.ArrayList;
|
|
|
import java.util.List;
|
|
|
import java.util.Map;
|
|
|
import java.util.concurrent.ConcurrentHashMap;
|
|
|
+import java.util.stream.Stream;
|
|
|
|
|
|
/**
|
|
|
* Hermes Bridge HTTP 客户端。
|
|
|
@@ -124,8 +129,10 @@ public class HermesBridgeClient {
|
|
|
Map<String, Object> body = new ConcurrentHashMap<>();
|
|
|
body.put("user_message", userMessage);
|
|
|
body.put("max_iterations", maxIterations);
|
|
|
- if (systemPrompt != null && !systemPrompt.isBlank()) {
|
|
|
- body.put("system_prompt", systemPrompt);
|
|
|
+ // 注入「可用技能列表 + 高效执行策略」前置 prompt,减少 LLM 猜名失败与上下文膨胀
|
|
|
+ String enhancedSystemPrompt = buildEnhancedSystemPrompt(hermesHome, systemPrompt);
|
|
|
+ if (enhancedSystemPrompt != null && !enhancedSystemPrompt.isBlank()) {
|
|
|
+ body.put("system_prompt", enhancedSystemPrompt);
|
|
|
}
|
|
|
if (hermesHome != null && !hermesHome.isBlank()) {
|
|
|
body.put("hermes_home", hermesHome);
|
|
|
@@ -177,7 +184,9 @@ public class HermesBridgeClient {
|
|
|
conn.getOutputStream().flush();
|
|
|
|
|
|
int responseCode = conn.getResponseCode();
|
|
|
- long elapsed = System.currentTimeMillis() - startTime;
|
|
|
+ // connectMs = 建立连接 + 写出 body + 收到 HTTP 响应头的时间
|
|
|
+ // (注意:SSE 的响应头几乎立刻到达,body 还在流式传输,所以这里耗时很短)
|
|
|
+ long connectMs = System.currentTimeMillis() - startTime;
|
|
|
if (responseCode != 200) {
|
|
|
String error;
|
|
|
try (var reader = new BufferedReader(new InputStreamReader(conn.getErrorStream(), StandardCharsets.UTF_8))) {
|
|
|
@@ -186,14 +195,20 @@ public class HermesBridgeClient {
|
|
|
while ((line = reader.readLine()) != null) sb.append(line);
|
|
|
error = sb.toString();
|
|
|
}
|
|
|
- LLM_HTTP.debug("[Hermes-RESP] HTTP {} ({}ms), error: {}", responseCode, elapsed, error);
|
|
|
+ long totalMs = connectMs;
|
|
|
+ LLM_HTTP.debug("[Hermes-RESP] HTTP {} (total={}ms), error: {}", responseCode, totalMs, error);
|
|
|
throw new RuntimeException("Hermes Bridge 返回 HTTP " + responseCode + ": " + error);
|
|
|
}
|
|
|
|
|
|
+ long streamStart = System.currentTimeMillis();
|
|
|
HermesRunResult result = parseSseResponse(conn, nodeId, sink);
|
|
|
- // === 响应 DEBUG 日志 ===
|
|
|
- LLM_HTTP.debug("[Hermes-RESP] HTTP 200 ({}ms), finalText 长度={}, logs 数={}",
|
|
|
- elapsed,
|
|
|
+ // streamMs = 读取 SSE 流式 body 的耗时(这才是真正的 Agent 执行时间,包含所有 LLM 调用与工具循环)
|
|
|
+ long streamMs = System.currentTimeMillis() - streamStart;
|
|
|
+ long totalMs = System.currentTimeMillis() - startTime;
|
|
|
+
|
|
|
+ // === 响应 DEBUG 日志(分别记录连接耗时与流式读取耗时,便于定位卡点) ===
|
|
|
+ LLM_HTTP.debug("[Hermes-RESP] HTTP 200, connect={}ms, stream={}ms, total={}ms, finalText 长度={}, logs 数={}",
|
|
|
+ connectMs, streamMs, totalMs,
|
|
|
result.getFinalText() == null ? 0 : result.getFinalText().length(),
|
|
|
result.getLogs() == null ? 0 : result.getLogs().size());
|
|
|
LLM_HTTP.debug("[Hermes-RESP] finalText: {}", result.getFinalText());
|
|
|
@@ -285,4 +300,83 @@ public class HermesBridgeClient {
|
|
|
if (text == null) return null;
|
|
|
return text.length() <= maxLen ? text : text.substring(0, maxLen) + "...";
|
|
|
}
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 高效执行策略前置 prompt。
|
|
|
+ *
|
|
|
+ * <p>用于解决 Hermes Agent 执行慢的几个根因(基于日志分析):</p>
|
|
|
+ * <ul>
|
|
|
+ * <li>② LLM 猜错技能名导致无效工具调用(readme-gen / git-commit-log-gen 等不存在)</li>
|
|
|
+ * <li>③ 中间轮次过度消化工具结果,每轮都生成几百 token 的「思考总结」</li>
|
|
|
+ * <li>④ read_file 整个大文件导致 in= token 雪球式膨胀(23k→50k)</li>
|
|
|
+ * <li>⑤ 探索完成后仍在循环验证,多出 2-3 轮无意义 LLM 调用</li>
|
|
|
+ * </ul>
|
|
|
+ *
|
|
|
+ * <p>这是「软优化」——通过 prompt 引导 LLM 自主控制上下文,而非硬性截断。
|
|
|
+ * 对比修改 hermes-agent 内部的 trajectory_compressor,这种方式风险最低、维护成本最小。</p>
|
|
|
+ */
|
|
|
+ private static final String EFFICIENCY_PROMPT_TEMPLATE =
|
|
|
+ "=== 执行策略提示(必须严格遵守)===\n\n" +
|
|
|
+ "## 可用技能(skill_view 调用时请使用精确名,禁止猜测)\n%s\n\n" +
|
|
|
+ "## 高效执行原则\n" +
|
|
|
+ "1. 一次性产出:明确目标后,应一次性生成所有需要的文件/输出," +
|
|
|
+ "不要每完成一个小步骤就停下来生成中间总结,避免多消耗 1 轮 LLM 调用。\n" +
|
|
|
+ "2. 摘要工具优先:当文件较大(>200 行)时,优先使用 head_file / search_files / grep " +
|
|
|
+ "等摘要工具定位关键内容,避免直接 read_file 整个大文件——上下文膨胀会让后续每一轮都更慢。\n" +
|
|
|
+ "3. 控制探索深度:达到目标即停止,不要重复读取已知信息、反复验证;" +
|
|
|
+ "任务完成后用一句话告知「已完成」,不要生成冗长总结报告。\n" +
|
|
|
+ "4. 工具结果复用:上一轮已经读到的内容,本轮不要重复读取;" +
|
|
|
+ "如需引用,直接基于已读内容作答。\n\n" +
|
|
|
+ "## 注意\n" +
|
|
|
+ "上述原则是「在合适场景下」的优化指引。当任务确实需要逐项探索(如多文件批量分析)时," +
|
|
|
+ "可按需展开;但不要为了「显得认真」而增加无意义的中间步骤。\n";
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 在原始 system_prompt 前追加「执行策略提示」。
|
|
|
+ *
|
|
|
+ * @param hermesHome 本次运行的 HERMES_HOME(其下应有 skills 子目录),用于扫描可用技能列表
|
|
|
+ * @param basePrompt 调用方提供的原始 system_prompt(如 SKILL.md 内容),可为 null
|
|
|
+ * @return 增强后的 system_prompt;若 basePrompt 为空且无可用技能列表,返回 null(不发 system_prompt)
|
|
|
+ */
|
|
|
+ private String buildEnhancedSystemPrompt(String hermesHome, String basePrompt) {
|
|
|
+ String skillsList = scanAvailableSkills(hermesHome);
|
|
|
+ String efficiencySection = String.format(EFFICIENCY_PROMPT_TEMPLATE, skillsList);
|
|
|
+
|
|
|
+ if (basePrompt == null || basePrompt.isBlank()) {
|
|
|
+ return efficiencySection;
|
|
|
+ }
|
|
|
+ return efficiencySection + "\n---\n\n" + basePrompt;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 扫描 HERMES_HOME/skills 目录,返回逗号分隔的技能 folderName 列表。
|
|
|
+ * 扫描失败时返回占位文案(不影响主流程)。
|
|
|
+ *
|
|
|
+ * <p>每次扫描开销很小(单次 Files.list,几十毫秒级),相对于 Hermes 整体上百秒的耗时可以忽略,
|
|
|
+ * 因此不做缓存——这也避免了「运行时新增技能但缓存未更新」的问题。</p>
|
|
|
+ */
|
|
|
+ private String scanAvailableSkills(String hermesHome) {
|
|
|
+ if (hermesHome == null || hermesHome.isBlank()) {
|
|
|
+ return "(未提供 hermes_home,无法列出)";
|
|
|
+ }
|
|
|
+ Path skillsDir = Paths.get(hermesHome, "skills");
|
|
|
+ if (!Files.isDirectory(skillsDir)) {
|
|
|
+ return "(skills 目录不存在于 " + skillsDir + ")";
|
|
|
+ }
|
|
|
+ List<String> names = new ArrayList<>();
|
|
|
+ try (Stream<Path> entries = Files.list(skillsDir)) {
|
|
|
+ entries.filter(Files::isDirectory)
|
|
|
+ .map(p -> p.getFileName().toString())
|
|
|
+ .filter(s -> !s.startsWith(".") && !s.equals("__pycache__"))
|
|
|
+ .sorted()
|
|
|
+ .forEach(names::add);
|
|
|
+ } catch (IOException e) {
|
|
|
+ log.warn("[HermesBridgeClient] 扫描技能目录失败: {}", e.getMessage());
|
|
|
+ return "(扫描失败: " + e.getMessage() + ")";
|
|
|
+ }
|
|
|
+ if (names.isEmpty()) {
|
|
|
+ return "(无)";
|
|
|
+ }
|
|
|
+ return String.join(", ", names);
|
|
|
+ }
|
|
|
}
|