mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
fix(prompt): 拼装时空行翻倍 + 人格支持版本占位符 + 配置项说明对齐分层原则
## 1. 提示词里的空行翻倍(用户实测发现)
抓包看实际 payload,system 里出现 3 处连续 3~4 个换行:
…别包装成"稍等我看一下"。\n\n\n【人格设定】
…/mascot.webp 直接能看。\n\n\n\n【场景记忆】
…能否同轮发出。\n\n\n【输出规则】
根因:拼装统一用 `prompt += "\n\n" + block`,而块自己常以换行结尾
(人格块是 "【人格设定】\n%s\n",自定义 system_prompt 也常带尾部换行)。
空行不致命但推高 token,也让「提示词到底长什么样」难以肉眼核对。
修法:新增 appendPromptBlock(trim 块两端 + 只拼一个 "\n\n"),所有拼装点改用它。
重新抓包:连续 3+ 换行 0 处。
## 2. 人格块支持版本占位符
buildSystemPrompt 只对 systemPrompt 调 expandPromptVars,人格块没展开 ——
人格里写死版本号仍会随发版说谎(实测线上人格卡写 v1.0.3)。现在人格同样展开
{{kernel_version}}/{{kernel_commit}}/{{sdk_version}}。
## 3. 人格/系统提示词两个配置项的说明重写
- core.agent.personal_prompt:DisplayName 改为「人格设定(称呼/语气/说话方式/形象)」,
说明只写人设;工具用法写工具描述、多步流程写技能;支持占位符;文件优先。
- core.agent.system_prompt:DisplayName 改为「系统提示词(身份与行为规则)」,
说明只写跨工具/跨通道成立的内容;运行时通道投递/并行/时序语义由内核注入无需重复;
留在提示词里的工具用法会随插件外置而失真。
## 4. 【记忆清理指令】→【记忆维护】
具体选哪个 memory_* 工具、各自副作用早已写在工具描述里(【记忆清理】前缀),
提示词里那份逐条列举属于重复。只保留「必须真做、不能只描述计划」这条
不看工具描述也会犯的错,并要求用一句人话说结果。
## 5. 新增 opt-in 提示词抓包(排查用)
HOMED_PROMPT_DUMP=<path> 时把每轮实际发给 LLM 的 messages 落 jsonl(0600)。
环境变量而非常规配置项:内容是用户隐私,只该在诊断时临时开。
本次排查即由它拿到证据;诊断后已移除临时 drop-in。
验证:go test(agent core / config / sdk / healthcheck)全绿;
实例重启后抓包复核空行归零;对话口吻仍为「短、口语、不列清单」。
This commit is contained in:
@ -103,7 +103,9 @@ func TestPromptKeepsExistingContract(t *testing.T) {
|
||||
"【输出规则】",
|
||||
"output_list_channels",
|
||||
"【可用工具能力】",
|
||||
"【记忆清理指令】",
|
||||
// 记忆维护块:标题从【记忆清理指令】改为【记忆维护】——具体工具选择
|
||||
// 下沉到 memory_* 的工具描述,提示词只保留「必须真做、不许只描述计划」。
|
||||
"【记忆维护】",
|
||||
} {
|
||||
if !strings.Contains(p, want) {
|
||||
t.Errorf("提示词丢失既有要点 %q", want)
|
||||
|
||||
68
internal/agent/core/promptdump.go
Normal file
68
internal/agent/core/promptdump.go
Normal file
@ -0,0 +1,68 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||||
)
|
||||
|
||||
// 提示词抓包(opt-in 诊断)。
|
||||
//
|
||||
// 为什么需要:系统提示词是**每轮动态拼装**的(身份 + 人格 + 记忆 + 时序 +
|
||||
// 工具顺序 + 输出规则 + 技能索引 + 首启门禁…),只看代码永远说不清模型
|
||||
// 这一轮到底收到了什么。排查「它怎么又列清单了」「为什么它说有某能力」
|
||||
// 这类问题时,唯一可靠的事实是**实际发出去的 payload**。
|
||||
//
|
||||
// 开关是环境变量而不是配置项:它会把记忆与用户输入原样落盘,属于
|
||||
// 临时诊断手段,不该在界面上有一个能被随手打开的开关。
|
||||
//
|
||||
// HOMED_PROMPT_DUMP=/var/log/homeagent/prompt.jsonl
|
||||
//
|
||||
// 每行一条 JSON:时间、通道、模型、完整 messages(role + content)。
|
||||
// 文件权限 0600 —— 内容是用户隐私。
|
||||
const promptDumpEnv = "HOMED_PROMPT_DUMP"
|
||||
|
||||
var promptDumpMu sync.Mutex
|
||||
|
||||
func dumpPrompt(channel string, req *agentAPI.CompletionRequest) {
|
||||
path := os.Getenv(promptDumpEnv)
|
||||
if path == "" || req == nil {
|
||||
return
|
||||
}
|
||||
entry := map[string]interface{}{
|
||||
"at": time.Now().Format(time.RFC3339Nano),
|
||||
"channel": channel,
|
||||
"model": req.Model,
|
||||
}
|
||||
msgs := make([]map[string]interface{}, 0, len(req.Messages))
|
||||
for _, m := range req.Messages {
|
||||
item := map[string]interface{}{"role": m.Role, "content": m.Content}
|
||||
if len(m.Blocks) > 0 {
|
||||
item["blocks"] = len(m.Blocks)
|
||||
}
|
||||
if len(m.ToolCalls) > 0 {
|
||||
item["tool_calls"] = len(m.ToolCalls)
|
||||
}
|
||||
msgs = append(msgs, item)
|
||||
}
|
||||
entry["messages"] = msgs
|
||||
entry["tool_count"] = len(req.Tools)
|
||||
|
||||
line, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
line = append(line, '\n')
|
||||
|
||||
promptDumpMu.Lock()
|
||||
defer promptDumpMu.Unlock()
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
f.Write(line)
|
||||
}
|
||||
@ -1297,6 +1297,9 @@ func (a *Agent) callLLMWithFallback(req *agentAPI.CompletionRequest, providers [
|
||||
}
|
||||
}
|
||||
|
||||
// opt-in 提示词抓包(HOMED_PROMPT_DUMP):见 promptdump.go。
|
||||
dumpPrompt(channel, req)
|
||||
|
||||
fCtx, fCancel := context.WithCancel(a.ctx)
|
||||
a.llmMu.Lock()
|
||||
a.cancelLLM = fCancel
|
||||
|
||||
@ -131,6 +131,29 @@ func expandPromptVars(s string) string {
|
||||
).Replace(s)
|
||||
}
|
||||
|
||||
// appendPromptBlock 把一个「整块」提示词接到已拼好的提示词后面。
|
||||
//
|
||||
// 为什么不能直接 `prompt += "\n\n" + block`:块自己常常以换行结尾
|
||||
// (人格块是 "【人格设定】\n%s\n"、自定义 system_prompt 一般也有尾部换行),
|
||||
// 直接拼就会得到 3~4 个连续换行 —— 实测一轮请求里出现 3 处:
|
||||
//
|
||||
// …别包装成"稍等我看一下"。\n\n\n【人格设定】 ← 多一个空行
|
||||
// …/mascot.webp 直接能看。\n\n\n\n【场景记忆】 ← 多两个空行
|
||||
// …能否同轮发出。\n\n\n【输出规则】 ← 多一个空行
|
||||
//
|
||||
// 空行本身不致命,但会推高 token、也让「提示词到底长什么样」难以肉眼核对。
|
||||
// 这里统一 trim 掉块两端空白再拼两个换行。
|
||||
func appendPromptBlock(prompt, block string) string {
|
||||
block = strings.TrimSpace(block)
|
||||
if block == "" {
|
||||
return prompt
|
||||
}
|
||||
if prompt == "" {
|
||||
return block
|
||||
}
|
||||
return strings.TrimRight(prompt, "\n") + "\n\n" + block
|
||||
}
|
||||
|
||||
func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
|
||||
prompt := expandPromptVars(a.systemPrompt)
|
||||
if prompt == "" {
|
||||
@ -139,20 +162,25 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
|
||||
|
||||
// 驻留子:在**固定提示词之上**注入任务提示词(设计 §7「创建」)。
|
||||
if a.taskPrompt != "" {
|
||||
prompt += "\n\n【任务】" + a.taskPrompt
|
||||
prompt = appendPromptBlock(prompt, "【任务】"+a.taskPrompt)
|
||||
}
|
||||
|
||||
if a.personality != nil {
|
||||
if pp := a.personality.InjectPrompt(); pp != "" {
|
||||
prompt += "\n\n" + pp
|
||||
// 人格卡同样展开版本占位符:人格里写死版本号会随发版说谎
|
||||
// (实测线上人格卡写 v1.0.3,内核早已 1.3.x)。
|
||||
prompt = appendPromptBlock(prompt, expandPromptVars(pp))
|
||||
}
|
||||
}
|
||||
|
||||
if memContext != "" {
|
||||
prompt += "\n\n" + memContext
|
||||
prompt = appendPromptBlock(prompt, memContext)
|
||||
}
|
||||
|
||||
prompt += "\n\n【记忆清理指令】当用户要求整理或清理记忆时,你必须实际调用 memory_ 工具执行操作,不能只回复文本。先用 memory_introspect 查看概况,再用 memory_recall 获取详情。有同义实体则用 memory_merge 合并(source 会被彻底删除),有无用噪音实体则用 memory_delete_entity 直接删除,也可用 memory_purge 批量清理,用 memory_edit 修正错误,用 memory_block_merge 标记不合并。如果工具执行成功,把结果告知用户;不要只描述计划而不执行。"
|
||||
// 记忆维护:具体选哪个工具、各有什么副作用,都写在 memory_* 的工具描述里
|
||||
//(【记忆清理】前缀)。这里只保留那条**不看描述也会犯**的错:只描述计划不执行。
|
||||
prompt = appendPromptBlock(prompt, "【记忆维护】用户要求整理或清理记忆时,必须真的调用 memory_* 工具去做,"+
|
||||
"不能只描述计划;选哪个工具、各有什么副作用见对应工具描述。做完用一句人话说结果。")
|
||||
|
||||
// 跨模态召回:文本路(fastText/TF-IDF 文档层,媒体描述文本已随记忆进入)
|
||||
// + 视觉路(多模态文本编码 → 媒体库坐标)两路归一化融合。
|
||||
@ -160,11 +188,11 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
|
||||
if a.docStore != nil {
|
||||
hits := a.retrieveCrossModal(userInput, 3, a.fusionCfg)
|
||||
if md := a.crossModalMarkdown(hits); md != "" {
|
||||
prompt += "\n\n" + md
|
||||
prompt = appendPromptBlock(prompt, md)
|
||||
}
|
||||
}
|
||||
|
||||
prompt += "\n\n【中断消息】长任务执行期间,工具/插件/定时器等会通过中断机制向你发送提醒(如 QQ 新消息、终端输出到达、定时器到点等)。中断消息以 system 角色注入,内容带 [中断消息] 前缀,**不是用户发言,但也必须认真处理**:优先停下当前长任务,针对中断内容作出响应或决定继续执行。不要忽略带 [中断消息] 前缀的 system 消息。"
|
||||
prompt = appendPromptBlock(prompt, "【中断消息】长任务执行期间,工具/插件/定时器等会通过中断机制向你发送提醒(如 QQ 新消息、终端输出到达、定时器到点等)。中断消息以 system 角色注入,内容带 [中断消息] 前缀,**不是用户发言,但也必须认真处理**:优先停下当前长任务,针对中断内容作出响应或决定继续执行。不要忽略带 [中断消息] 前缀的 system 消息。")
|
||||
|
||||
// 工具执行顺序(阶段 2.5)。必须在**并行执行落地之后**才加:
|
||||
// 反序会让本段对模型说谎——说"并发"而内核仍串行,模型据此推断
|
||||
@ -172,13 +200,13 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
|
||||
//
|
||||
// ⚠️ 措辞必须与 batchRunnable 的**真实**判据一致(全批 ParallelSafe
|
||||
// 才并发 + 同通道保序),否则只是把"没说"换成"说错"。
|
||||
prompt += "\n\n【工具执行顺序】同一条回复里给出多个工具调用时,内核**默认并行执行**(同时跑),不是依次执行。这带来三条你必须知道的规则:\n"
|
||||
prompt = appendPromptBlock(prompt, "【工具执行顺序】同一条回复里给出多个工具调用时,内核**默认并行执行**(同时跑),不是依次执行。这带来三条你必须知道的规则:\n")
|
||||
prompt += "- **不要依赖执行顺序**:若某个调用的参数需要另一个调用的结果,就**分两轮**——先发前一个,看到结果后再发下一个。写在同一轮里就等于假设了不存在的先后。\n"
|
||||
prompt += "- **例外一:同一通道的输出发送会保序**。对**同一个**通道连续调用多次 `output_send__`,内核会**按你发出的顺序依次执行**(用户可见消息顺序敏感),不会并发打乱。所以「先发回执、再发结论」这类有序发送可以放在同一轮;但若后续内容依赖前一条的用户反应,仍应分轮。\n"
|
||||
prompt += "- **例外二:不并发安全的工具不会并发**。写类工具(记忆/知识/文档写入等)与未声明并发安全的工具,**整批会退回依次执行**——同一批里只要有一个这样的工具,这一批就全部串行。这对你是透明的:你不需要判断,只需知道「同轮并发」不是无条件保证的。\n"
|
||||
prompt += "- 工具是否并发安全由**工具自己声明**(`ParallelSafe`),不由你决定;你只需按上面第 1 条判断能否同轮发出。\n"
|
||||
|
||||
prompt += "\n\n【输出规则】消息不会自动发送到对话来源通道,你必须自己决定如何回复:\n"
|
||||
prompt = appendPromptBlock(prompt, "【输出规则】消息不会自动发送到对话来源通道,你必须自己决定如何回复:\n")
|
||||
prompt += "- **不要假设当前通道是某个固定值**:同一会话里可能同时有多个来源(多设备、多通道、子任务)。\n"
|
||||
prompt += " 先看这条消息本身与上下文里的来源信息,再决定往哪里回;不确定有哪些通道时先调 output_list_channels。\n"
|
||||
prompt += "- 同步通道(webui / cli / 终端):直接返回纯文本,内核会把文本交给等待方显示,无需调用工具。\n"
|
||||
@ -203,7 +231,7 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
|
||||
// 主动 skill_info 拉取全文按文档执行
|
||||
if a.skillIndex != nil {
|
||||
if idx := a.skillIndex.SkillIndex(); idx != "" {
|
||||
prompt += "\n\n【可用技能】以下是已安装的原生技能。当用户请求与某技能描述匹配时,\n先用 skill_info(\"技能名\") 拉取全文,再严格按文档步骤执行:\n" + idx
|
||||
prompt = appendPromptBlock(prompt, "【可用技能】以下是已安装的原生技能。当用户请求与某技能描述匹配时,\n先用 skill_info(\"技能名\") 拉取全文,再严格按文档步骤执行:\n"+idx)
|
||||
}
|
||||
}
|
||||
|
||||
@ -211,10 +239,10 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
|
||||
// 系统提示词每轮重建,因此 WebUI / QQ / CLI / ACP / 邮件等所有通道都会带上它;
|
||||
// 模型调用 persona_set(或用户在 WebUI 向导里选)落地后,标记置位,本段消失。
|
||||
if a.personaStore != nil && !a.personaStore.PersonaInitialized() {
|
||||
prompt += "\n\n【首启人格设定】你的**人格设定尚未确认**。请在本轮回复里先问用户一句:" +
|
||||
"要用默认人格,还是自定义一个?拿到明确答复后**必须调用 persona_set 工具**落库:" +
|
||||
"用户选默认 → mode=default;自定义 → mode=custom 且把内容写进 content;" +
|
||||
"用户说以后再说 → mode=later。用户答复前不要假设已设置,也不要反复追问同一件事。"
|
||||
prompt = appendPromptBlock(prompt, "【首启人格设定】你的**人格设定尚未确认**。请在本轮回复里先问用户一句:"+
|
||||
"要用默认人格,还是自定义一个?拿到明确答复后**必须调用 persona_set 工具**落库:"+
|
||||
"用户选默认 → mode=default;自定义 → mode=custom 且把内容写进 content;"+
|
||||
"用户说以后再说 → mode=later。用户答复前不要假设已设置,也不要反复追问同一件事。")
|
||||
}
|
||||
|
||||
prompt += a.buildToolCatalog()
|
||||
|
||||
@ -843,10 +843,13 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string, fresh bool) {
|
||||
|
||||
// 人格设定(人格卡的配置项化):默认模板见 DefaultPersonaPrompt。
|
||||
// 高级用户仍可用 <dataDir>/personal/personal.md 覆盖它。
|
||||
reg(ConfigDef{Key: "core.agent.personal_prompt", Default: DefaultPersonaPrompt, Type: "text", DisplayName: "人格设定",
|
||||
Description: "人格设定块(作为【人格设定】拼在系统提示词之前)。留空则该块不注入。" +
|
||||
"默认模板不含版本号:被问到版本/构建信息时,应读运行时快照而非凭记忆断言。" +
|
||||
"<dataDir>/personal/personal.md 存在且非空时优先于本项。", Category: "agent"})
|
||||
reg(ConfigDef{Key: "core.agent.personal_prompt", Default: DefaultPersonaPrompt, Type: "text", DisplayName: "人格设定(称呼/语气/说话方式/形象)",
|
||||
Description: "人格块正文,作为【人格设定】拼进系统提示词。**只写人设**:怎么称呼用户、语气与说话方式、" +
|
||||
"语言习惯、形象设定。工具用法写工具自己的描述,多步流程写技能(skill),不要写在这里 —— " +
|
||||
"写在人格里会随插件增删而失真(工具被卸载/未启动时提示词还在教模型用它)。" +
|
||||
"支持版本占位符 {{kernel_version}} / {{kernel_commit}} / {{sdk_version}}(随构建实时展开,避免写死版本号)。" +
|
||||
"<dataDir>/personal/personal.md 存在且非空时**优先于本项**(该文件不支持占位符展开,建议逐步迁到本项)。" +
|
||||
"留空则该块不注入。修改后需重启生效。", Category: "agent"})
|
||||
|
||||
reg(ConfigDef{Key: "webui.listen_addr", Default: ":8080", Type: "string", DisplayName: "监听地址", Description: "WebUI HTTP 监听地址", Category: "webui"})
|
||||
reg(ConfigDef{Key: "core.daemon.data_dir", Default: dataDir, Type: "string", DisplayName: "数据目录", Description: "数据存储根目录", Category: "daemon"})
|
||||
@ -926,7 +929,14 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string, fresh bool) {
|
||||
reg(ConfigDef{Key: "core.agent.workdir", Default: "", Type: "string", DisplayName: "工作目录", Description: "Agent 命令执行的默认工作目录(如 cmd_run 工具的 fallback),留空使用内核所在目录", Category: "agent"})
|
||||
reg(ConfigDef{Key: "core.agent.embedding_model_path", Default: "", Type: "string", DisplayName: "预训练词嵌入模型路径", Description: "预训练词嵌入模型路径(word2vec 文本格式),支持逗号分隔多个模型。路径后可加 #topN 规格只加载前 N 个词向量(如 /data/cc.zh.300.vec#top50000)以控制常驻内存,词频降序命中覆盖绝大部分文本。空则使用 TF-IDF 回退。修改后需重启生效。", Category: "agent"})
|
||||
reg(ConfigDef{Key: "core.agent.onnx_model_path", Default: "", Type: "string", DisplayName: "ONNX 模型路径", Description: "依存句法分析 ONNX 模型文件路径。留空使用二进制内嵌模型/规则引擎。修改后需重启生效。", Category: "agent"})
|
||||
regMeta(ConfigDef{Key: "core.agent.system_prompt", Default: "", Type: "text", DisplayName: "系统身份提示词", Description: "Agent 的系统提示词,定义身份和行为规则。留空则使用编译时内置默认值。支持版本占位符(随构建实时展开,避免写死版本号随发版说谎):{{kernel_version}}、{{kernel_commit}}、{{sdk_version}}。修改后需重启生效。", Category: "agent"})
|
||||
regMeta(ConfigDef{Key: "core.agent.system_prompt", Default: "", Type: "text", DisplayName: "系统提示词(身份与行为规则)",
|
||||
Description: "系统提示词正文,定义身份与行为规则(不是工具说明书)。**只写跨工具/跨通道都成立的内容**:" +
|
||||
"身份定位、正确性约束(不编造、只依据工具返回值)、回复的基本要求。" +
|
||||
"单工具「何时用/参数/坑」写该工具自己的 description;多步流程写技能(skill_info 按需取);" +
|
||||
"称呼/语气/说话方式写「人格设定」。写在系统提示词里的工具用法会随插件外置/按需加载而失真。" +
|
||||
"运行时的通道投递规则、同轮并行语义、对话时序语义由内核每轮自动注入,无需在此重复。" +
|
||||
"留空则使用编译时内置默认值。支持版本占位符(随构建实时展开,避免写死版本号):" +
|
||||
"{{kernel_version}}、{{kernel_commit}}、{{sdk_version}}。修改后需重启生效。", Category: "agent"})
|
||||
|
||||
reg(ConfigDef{Key: "core.input_processing.image.fallback_provider", Default: "", Type: "string", DisplayName: "图片回退提供商", Description: "当主 LLM 不支持图片处理时使用的提供商(留空则自动降级为文字描述)", Category: "input"})
|
||||
reg(ConfigDef{Key: "core.input_processing.image.fallback_model", Default: "", Type: "string", DisplayName: "图片回退模型", Description: "图片回退提供商使用的模型名", Category: "input"})
|
||||
|
||||
@ -68,7 +68,8 @@ const defaultSystemPrompt = `你是 HomeAgent,一个持续运行的个人管
|
||||
|
||||
只根据工具真实返回的内容作答:没取到就如实说没取到,不要补全、猜测或编造。
|
||||
|
||||
回复用自然语言与用户交流。`
|
||||
回复用自然语言,像跟人聊天:不要罗列调用了哪个工具、参数是什么、耗时多少,
|
||||
除非用户明确要求。称呼、语气与说话方式见【人格设定】。`
|
||||
|
||||
func buildAgent(k *kernelboot.KernelState) *agentCore.Agent {
|
||||
cfg := k.Cfg
|
||||
|
||||
Reference in New Issue
Block a user