14 KiB
Raw Blame History

name description
cockpit-agent-patterns Cockpit session agent anti-patterns and fixes.

Cockpit Agent 设计模式

会话 agentcockpit_chat.dspy开发中发现的陷阱和修复。

对话历史结构化

反模式: 历史以独立 user/assistant 角色混入 LLM 上下文LLM 模仿旧 tool_call。

正确做法: 聚合为系统级"参考历史记录:",用户当前输入为唯一 user 消息:

[system] 参考历史记录:
  用户: 切换到人事系统
  助手: OK 已切换
[user] 人事系统项目当前进展  ← 唯一主输入,历史不会污染决策

项目名匹配

反模式: _find_project 硬编码精确/子串匹配。

正确做法: 精确匹配失败时调 _call_llm_raw(temp=0) 做文本分类——给 LLM 用户输入和项目列表,让它选。不要硬编码匹配逻辑。

owner 级配置 get/set 不匹配(保存后看不到)

反模式: 工作环境等 owner 级配置user/orgget_work_env(sor, user_id, org_id) 读取,优先级 user → org。专门的 org 级配置页面(如 sd_org_remote「机构远程空间」加载时走默认 get返回的是 user 级配置(往往是空/local用户保存到 org 级后退出再进get 又返回 user 级 → 看起来"保存没生效"。

修复: get API 加 owner_type 参数。org 页面加载传 owner_type=orgget_work_env(sor, '', org_id) 只查 org 级user 页面传 owner_type=user(只查 user 级)。前端加载 script 用 ?action=get&owner_type=org,与 set 的 owner_type='org' 对齐。

切换项目不持久化(下一轮回退老项目)

反模式: _t_switch_project/_t_create_project 只改 self.project_idAgentExecutor 实例内存字段),不写 pipeline_agent_settings。AgentExecutor 每轮 run 新建、run 结束销毁,所以切换在下一条消息就回退。

修复: switch/create 成功后必须持久化 current_project_idpipeline_agent_settingsuser_id 唯一键UPDATE-first + SELECT-check + INSERT 兜底)。否则 cockpit_chat_v2 每轮从 settings 读旧项目,历史隔离也跟着错(切到新项目却加载旧项目历史)。

需求描述被当"查进展"(新需求没被关注)

反模式: system_prompt 写"用工具查询数据" + 示例 diagnose_project,把 LLM 往"诊断/查询"带auto-inject 兜底又按关键词默认注入 diagnose_project/list_tasks。用户发需求描述含"系统/模块/任务"等词agent 去 diagnose 旧任务而不是 create_task 推进需求。

修复(三层):

  1. system_prompt 明确工作流:"描述需求→create_task+start_agents问进展→list_tasks/diagnose问问题→list_questions"。
  2. auto-inject 兜底加"新需求"启发式:len(input)>40 且含需求特征词(实现/开发/系统/功能/模块/切换/监控/备份/同步...) → 注入 create_task 引导,而非 diagnose。
  3. 需求特征词表放代码里prompt 保持简洁(不靠冗长禁令)。

create_project 不设置 workspace_dir所有项目共用 default 目录)

反模式: _t_create_projectsor.C("sd_projects", {id,name,description,status}),不设 workspace_dir/org_id。结果 _resolve_workspaceagent_loop.py遇到空 workspace_dir 回退 ~/pipeline_ws/default所有项目共用同一目录,设计文档/代码互相覆盖;而前端 get_workspace_pathworkspace.py回退 /d/pipeline/workspaces/<org>/<项目名>,两处不一致 → 前端工作空间浏览器显示"不可用",文档实际写在 default。

修复: create_project 时设 workspace_dir = os.path.join(workspace_base, org_id, name) + os.makedirsorg_idusers.orgid 查。workspace_base 不硬编码,从 appbase params 表读(params_name='workspace_base'params_value),带默认值 /d/pipeline/workspaces 兜底(_get_param try/exceptparams 表不存在时用默认值不 500。现有项目要手动 UPDATE sd_projects SET workspace_dir=... 并把 default 里的文档 mv 回项目目录。

tool_call JSON 泄露

反模式: LLM 在 reply 中夹杂 {"action":"tool_call",...}

修复: _parse() 用 regex 剥离 tool_call JSONrstrip('}') 清除残留花括号。

LLM 光说不练deepseek 特有问题)

反模式: "我来逐一回答"但不调 answer_question"启动Agent"但不调 start_agents。 更严重的是 deepseek-v4-pro即使 prompt 明确要求"第一步必须调用工具"、"禁止 reply"LLM 仍然 第一个动作就输出 {"action":"reply",...}。尝试了激进规则、few-shot 示例、移除 reply 格式选项、 精简到 3 行核心指令——全部无效。

修复(双阶段代码级兜底)

  1. 首轮注入_tool_call_count == 0 时):

    • 根据用户输入关键词推断合适工具任务→list_tasks问题→list_questions其他→diagnose_project
    • 拦截 reply追加 "请调用 {tool}只输出JSON" 到消息数组continue 继续 loop
  2. 延续推动_tool_call_count <= 2 时):

    • LLM 调了 1-2 次工具就想停 → 追加 "请继续查看失败任务详情、回答问题、或启动agent"
    • 防止 LLM 拿到诊断结果就 reply 而非继续深入

关键:_tool_call_count 只在工具成功返回时递增(结果不以 "未知工具" 或 "ERROR" 开头)。

换用 Claude/GPT-4 后此机制可移除——prompt 本身就能驱动工具调用。 详见: references/deepseek-tool-calling.md

⚠️ 2026-08-15 已废弃:上面的 auto-inject 双阶段兜底(硬编码 req_keywords + 强制注入 hint_tool已被移除。它把 agent 变成了"只能路由工具的傻子"——见下一节。诚实降级取代强制路由。

会话 agent 不是"路由器"——诚实降级 + 能力自省2026-08-15 重构)

反模式(三重病态叠加,把 agent 变成傻子路由器)

  1. system prompt 写 必须只输出 tool_call JSON禁止 reply → 剥夺 LLM 说"我做不到"的能力,面对超出工具集能力的指令只能硬套一个最接近的工具。
  2. auto-inject 用硬编码关键词(req_keywords=["实现","开发","系统","模块"...] + if kw in user_input)强制注入工具 → 违反"意图识别交给 LLM 不硬编码"铁律LLM 想停就被塞 hint_tool。
  3. ask_user 是假的:return f"QUESTION: {params['question']}" 把问题回传给 LLM 自己,用户看不到 → LLM 连"问用户"都做不到。

触发症状:用户指令超出工具集能力(如"重做三个模块"但无 reset_task 工具agent 不诚实说明,而是退化成 diagnose→list_tasks→list_deliverables→list_questions→run_command 找规范文件,最后卡在 run_command 的 confirm 弹窗requires_confirmation没人确认就搁置。

修复(把 Hermes 通用能力还给它)

  1. 删"禁止 reply"。prompt 写"能力自省与诚实降级:请求超出工具能力时,明确告诉用户你做不到+缺什么能力+给替代方案+必要时 ask_user 拍板"。
  2. 删 auto-inject 硬编码关键词。replyask_user 是合法终止动作(直接输出并 return不再强制注入工具
  3. ask_user 真正抛给用户:作为终止动作 yield {"type":"ask_user","message":q} 给前端,前端 cockpit_chat_v2.dspy 加 elif t == 'ask_user' 分支展示 ❓ 问题

用户定位(架构原则):会话 agent = Hermes 通用能力(推理/判断/诚实降级/能力自省/澄清)+ 项目工具,不是"只能路由、不能判断的壳"。auto-inject 只在"LLM 真·空转(连续 N 轮既无 tool_call 也无 reply/ask_user"时才温和提示一次,且不硬编码关键词。

Prompt 占位符未替换

反模式: system prompt 模板中有 {tools_description} 占位符,但 _build_system_prompt 将其作为字面文本传给 LLM——LLM 看到的是 ## 可用工具\n{tools_description} 而非实际工具列表。

修复: _build_system_prompt 中必须构建 tools_text 后用 prompt.replace("{tools_description}", tools_text) 替换。 同时 {current_project} 应替换为项目名而非项目 ID_load_project_context 获取名称)。

msgs 数组污染

反模式: msgs.append({"role":"assistant","content":raw}) 把原始 tool_call JSON 塞进 LLM 上下文。

修复: 改为 msgs.append({"role":"assistant","content":"已调用 {tool}"})

通知推送

反模式: 角色 agent 提问后会话 agent 不主动告知用户。

修复: 每轮开始查询 pending questionsyield 为第一条 NDJSON + 注入 system prompt。

回复控件

反模式: 最终回复用 Text 控件,不支持 markdown。

修复: 最终回复/通知用 MdWidget支持 粗体、列表、换行),工具结果用 Text。

_save_ctx 竞态

反模式: SELECT-then-INSERT 导致 duplicate key。

修复: UPDATE-first + try/exceptINSERT 仅兜底。

登录门禁

反模式: get_user() 返回 None 时静默失败。

修复: send_message/list_messages 入口检查None 返回"请先登录"。

claimed_by 僵尸锁PM 驳回后任务永久卡死)

反模式: PM agent 驳回交付件时只 UPDATE state='submitted',不清 claimed_by。 agent poller 条件 claimed_by IS NULL → 跳过此任务 → 任务永远不被认领。

修复: PM reject 路径必须 SET state='submitted', claimed_by=NULL。 同时定期清理:UPDATE pipeline_tasks SET claimed_by=NULL WHERE state='submitted' AND claimed_by IS NOT NULL

进程重启不生效start.sh kill -0 陷阱)

症状: 代码已部署到 site-packages重启后旧行为依旧。

根因: start.shkill -0 $(cat pipeline.pid) 检查旧进程。PID 文件被删但进程还活着时, kill -0 返回成功 → 打印 "Already running" → 跳过启动 → 旧进程代码永不被替换。

修复: 部署最后一步必须 pkill -9 -f pipeline_app 强制杀进程,再 bash start.sh

进阶陷阱(多进程并存 + pkill 255 断链)——重启流程两个独立坑,都会造成"改完还报旧错"

  1. pkill -9 -f pipeline_app没匹配到进程时返回 255,放在 && 命令链里会断掉后续的 bash start.sh。所以 kill 和 start 必须分两条命令执行,或用 ; 而非 &&,或用 ps aux|grep|awk '{print $2}'|xargs -r kill -9-r 空输入不报错)。

  2. 反复重启若没彻底杀,会累积多个 pipeline_app 进程,最早的进程占着 9090 端口,新启动的进程监听失败但 start.sh 仍打印 "Started"curl 打到旧进程 → 修复看起来"没生效"。改完代码必须验证:

ps aux | grep pipeline_app | grep -v grep | wc -l   # 必须 = 1

部署后先确认单进程、再 curl 验证,否则排查方向会被旧进程带偏(本次 remote_dir 相对路径验证连踩 3 次"假失败")。

LLM 编造工具名

反模式: LLM 输出 tapd_get_tasksrun_shell_commandget_task_detail 等不存在的工具名。

修复: 在 _dispatch_sdlc_tool 的 handlers dict 中加别名映射:

  • get_tasks / get_task_listlist_tasks
  • get_task / get_task_detailtask_detail
  • get_deliverableview_deliverable

list_tasks 输出格式化

反模式: "- [state] title (角色: role) id=xxx" 纯文本一坨MdWidget 渲染效果差。

修复: 输出 markdown 表格 + emoji 图标:

| 状态 | 任务 | 角色 | ID |
|------|------|------|----|
| ⏳ submitted | 开发模块 | design | abc123 |
| ✅ approved | ... | ... | ... |

用户意图识别(停止 / 补充 / 新任务)

反模式: 新消息到达时前端自动 abort 旧请求。

正确做法:

  • 后端检测停止关键词(停止/取消/停/stop/cancel→ 立即返回"已停止"
  • 其他消息照常处理为新对话轮次
  • 不前断 abort——用户可能在补充信息

工具结果 MdWidget 渲染

反模式: list_tasks 返回 markdown 表格,但 cockpit_chat_v2.dspy_w_card_w_text 包裹——表格显示为原始 |...| 文本。

修复: cockpit_chat_v2.dspytool_result handler 检测结果是否以 |, #, - 开头markdown 特征),是则用 _w_md(result[:2000]) 作为 card body

is_md = result.startswith('|') or result.startswith('#') or result.startswith('- ')
body = _w_md(result[:2000]) if is_md else result[:600]

Pipeline 浏览器登录

CDP 浏览器测试 pipeline cockpit 的登录流程:

  1. https://pipeline.opencomputing.cn/rbac/user/login.ui — Bricks 登录表单
  2. 填入 username/password如 admin/admin123点击 Form Submit 按钮
  3. 或用 fetch POST /rbac/user/up_login.dspy(注意必须有 /user/ 前缀)+ _webbricks_=1
  4. password_encode()ahserver/globalEnv.py,用 RC4 + 配置 keyDB 存的是 encoded 值
  5. 登录后 session 生效,导航到 /pipeline-sdlc 即可渲染 cockpit

验证前端的正确姿势SSRF 阻止私有地址)browser_navigate 会拒绝 localhost/127.0.0.1"Blocked: URL targets a private address"config browser.allow_private_urls:false),所以 SSH 隧道 localhost:19090 对浏览器工具无效——别在隧道上浪费时间。直接 browser_navigate生产公网地址 https://pipeline.opencomputing.cn/...(公网不触发 SSRF 防护)。先 browser_cdpTarget.getTargets 确认 CDP 可达config browser.cdp_url: localhost:9222),再导航。若页面 body 显示的是 .ui 的 JSON 源码而非渲染表单select/input 数量为 0、bricks.app 未 run是未登录/渲染失败,用 browser_consolejs_errors 定位,不是修复引入的问题。