【踩坑实录】本地 Qwen3.8-Flash-Next 在 agent 循环里"复读死循环":从 311×2 次重复调用到真根因(贪婪采样)
-
先把机器和环境交代清楚,免得后面对不上号(这也是我一开始的排查方向之一:先确认不是硬件/环境的问题)。
环境(完整)
机器
- CPU:AMD Ryzen 7 9700X(8 核 16 线程,最高 5.58 GHz,L3 32 MB),
amd_pstate=active,测性能时把 governor 调成performance、boost 打开 - 内存:59 GiB
- 主卡(跑模型的):AMD Radeon AI PRO R9700 32 GB(RDNA4 /
gfx1201),ROCm 7.2.0,走 HIP 后端 - 副卡:NVIDIA RTX 4060 8 GB(驱动 610.57.04)——跟这次推理无关,只是插着
- 系统:Ubuntu 26.04 LTS,内核
7.0.0-30-generic,x86_64
模型
- Qwen3.8-Flash-Next,IQ3_S 量化,83.6 GB 的 MoE,两个分片(GSQ-RCO)
- 上下文 256K,KV 用
int8、kv-resident 32768,MTP 草稿spec 4,专家缓存开着(GPU 常驻 + 约 28.6 GiB 内存页缓存) - 实测速度:decode 54~75 tokens/s(短 prompt 能到 ~89),prefill 约 500~1300 tokens/s(看上下文多长),专家缓存命中率 95~97%——日常用完全够
推理引擎
- Strata 0.1.32:开源项目(MIT,上游 github.com/Niko1221/Strata),官方定位是"用一张 12–24 G 的 NVIDIA 卡 + 64 G 内存,在自己电脑上跑 125B 的 Qwen3.8-Flash-Next",默认面向 N 卡 + Windows/Linux 一键安装
- 我们是把它搬到 AMD RDNA4 上跑(HIP/ROCm,加上 WMMA、hipBLASLt 调优表这些),所以配置里有一堆
STRATA_HIP_WMMA/STRATA_SELECT_WMMA/STRATA_PREFILL_MMQ之类的开关
️ 关键前提:这个引擎没配 sampling的时候默认是贪婪解码(greedy)——对单轮对话/代码生成这种要确定性输出的场景挺合理,但一放进 agent 循环就出大事
Agent 壳
- DeepSeek Harness(dsh)0.2.0-rc,agent 跑在 headless / web profile 里;权限模式设成完全放开,沙箱和审批都不介入(先把"卡审批"这条可能性排除掉)
- 任务是一个 Rust 项目(多文件)的改造:agent 得自己读规格文档 → 改代码 → 编译 → 跑测试 → 写自查报告
结论我先摆这儿,省得你往下翻半天:不是模型笨,是引擎默认走了贪婪解码。而 agent 每跑一步又会把"这次调用+结果"原样塞回上下文,上下文越长越自相似,贪婪一解码,输出就锁死在同一条上——稳定 2-周期。你在提示词里写一百遍"别重复",它照样重复。
一、现场:看着忙得飞起,其实 53 分钟在空转
看到的 实际 工具调用总数 773 其中两条一模一样的 bash 命令 各 311 次,加起来 622 次(80%) 长啥样 严格 A/B 交替:命令1→命令2→命令1→命令2…(最长"连续相同"只有 2) 那 311 次的结果 字节完全相同(同一个 MD5),而且不空、它想查的字段都在里面 有内容的助手消息 整个回合就 1 条("我已掌握全部信息,开始实现") 循环期间写文件 0 正式卡住到被我掐 ~53 分钟 掐掉之后
git status看一眼:干净。一个字没写。记个教训:"文件 mtime 是新的 / 事件数在涨 / 进程活着"这三件事,全都是假信号。 复读的时候这三样一个不缺。别拿它们判断"agent 在干活"。
二、我踩过的坑(这些假设全被我否了,贴出来给你省时间)
我一开始的猜测 后来怎么否的 上下文被裁剪,它"忘了自己读过" 时间线对不上:裁剪事件循环前后都有(11+4+1),而且循环期间每 10 分钟就裁一次。既不充分也不必要 grep 窗口开小了,没看到想要的字段 直接否:311 次输出完全相同、非空、字段齐全。信息一直在上下文里 它幻觉出一个不存在的类型,才反复 grep 只解释"为什么盯着同一个文件",不解释"结果一模一样还重发"。换个真名重查,照样循环 harness 根本没做重复检测 方向对、事实错:harness 自带重复提醒插件,默认还开着 那自带的守卫为啥没拦住?三层全漏:
- 它只数连续相同的调用 → 我这是 A/B 交替,"连续计数"永远是 1,阈值(3/5/8 三档)根本够不着 ⇒ 全场就响了 2 次;
- 它拿整个参数对象当键 → 模型只要把
description改一下("第 N 次执行"),再随手补两个无关字段,就一次都数不上; - 它只往上下文里塞提醒文字,不动控制流 → 后来实测提醒响了 8 次(连最高档都响了),模型在正文里连着写了三次"我已停止重复调用"——然后继续发同一条命令。
三、真根因:引擎默认贪婪 × 自相似上下文
证据是一步步挖出来的:
- 抓会话里每一轮的请求头:只有
{provider, model, maxTokens},没有 temperature; - 翻 harness 的适配层代码:只有显式设置了
options.temperature才会把它带上(if (options.temperature !== undefined)); - 看引擎侧 run config:没有
sampling段 → 走引擎默认,而这个默认就是贪婪; - 于是闭环成立:同一段上下文 → 同一个 argmax → 同一条命令;而 agent 每步都把"这次调用 + 结果"追加进上下文,上下文自相似 → 输出自相似 → 卡成 2-周期;
- 反证:引擎 run config 里加上
temperature=0.3之后,同一个请求连发 3 次,输出各不相同(贪婪时是逐字节一样的)。
为什么"写规矩"治不了这个病?
我在任务书里加过一整套硬条款(同一条命令最多 2 次、一次读够、连续 5 次没推进就停下写报告)。结果:模型嘴上照做、行为照旧。
道理很简单:贪婪解码下它没有"换个说法"的自由度。只要上下文差不多,输出就是那一条。这不是听话不听话的事。
四、修:两头一起上(有前后对照)
1)引擎侧——治根因(改配置,不用改引擎源码)
sampling: { temperature: 0.3 }- 为什么改引擎不改 harness:大多数 harness 没有 per-model 采样字段,改引擎一次覆盖所有客户端;请求里显式带了 temperature 时,还是以请求为准。
- 验证:引擎日志出现
sampling defaults from the config: temperature=0.3,同请求连发 3 次输出不同。
️ 有个副作用得知道:改完同一个输入不再有可复现的输出。以后别拿"同输入同输出"当验证手段了,要判稳不稳,就统计多次运行的重复率。
2)harness 侧——兜底熔断
判据(命中任一就动手):
- 滑窗:最近 20 次调用里,同一条"语义载荷"出现 ≥8 次(bash 取整条
command,读写取文件路径+内容)——滑窗这一条专门干"连续判据抓不到的 A/B 交替"; - 无信息:连续 10 次调用既没写文件、也没出现"以前没见过的调用"。
处置两级:
- 第一次命中:把这次调用拦下来,把"别重复了、用已有信息收尾"当工具结果还给模型;
- 再犯:直接结束回合并落日志。
写插件踩的两个坑:① harness 官方的"优雅收尾"接口在工具执行体外面调用没效果(收尾标记在更早阶段就结算了);② 拦调用时返回的反馈必须是"内容块数组",塞字符串会报
content.map is not a function。3)前后对照(同任务、同模型,隔离副本里跑,真实仓库零改动)
修之前 修之后 工具调用 101 153 滑窗内重复峰值 9(最长连续 9) 1 自带提醒命中 8 次(含最高档) 0 次(压根没复读) 写文件 0 新模块 13.6KB + 3 个文件改动,还顺带出了演示和性能数据 结局 回合 completed、退出码 0、答复"我已掌握全部信息,转入实现" ⇒ 假完工正常推进中,被我按时间上限掐断 再记一个坑:
退出码 0+completed≠ 有产出。 判断有没有干活,用"工作区指纹":git status --porcelain -uall+git diff --numstat+ 源码最新 mtime。别拿命令文本猜"这算不算写盘"——agent 爱用 bash heredoc 落盘,2>&1这种字串一眼看过去很像写操作,其实什么都不是。
五、同样在跑本地 agent 的,照这个清单查
- 先看你的客户端到底把采样参数发出去了没——抓一次请求体,看有没有
temperature。适配层"你不设置我就不带"是常态。 - 再看引擎/服务端默认用什么采样——不少后端默认就是 greedy(单轮推理常这么配),放到 agent 场景直接放大成复读。
- 重复判据用"滑窗内出现次数",别用"连续相同次数"——A/B 交替能骗过所有连续判据。
- 提醒 ≠ 拦截:只往上下文里塞提示的守卫,对这个病没用,必须"先拦调用、再结束回合"。
- 判产出用工作区指纹,别信 mtime / 事件数 / 进程存活 / 退出码。
- 调参和复现一定放隔离副本里跑:agent 会照着文档里的绝对路径直接改你的真实仓库(实测它的 grep 全打在真实仓库路径上)。
unshare -m+ bind 挂一个副本最省事。 - 把"假完工"当一等失效模式:回合 completed、工作区纹丝不动——长任务里这比直接崩还贵。
六、还没搞定的
temperature=0.3是"能跑"的起点,我没做系统调参。要是发现输出发散、格式不稳,可以试0.2 + repetition_penalty 1.05,判据还是"多次运行的重复率",别看单次。- 熔断里"语义载荷"的定义:bash 取整条命令、写工具取"路径+内容"。实测仍有小概率把同一个文件的密集分段编辑误判成重复(后来把写工具的键改成"剔除包装字段后的整参数"——只改 description 的重复照样抓到,内容变了就不算重复)。
- 这是单机、单模型、单个任务的经验(n=1)。如果你也遇到过"交替型"复读,欢迎补充:别的模型/引擎是不是也能用"greedy + 自相似上下文"解释掉。
七、附:后端模型启动脚本(可直接抄)
1)引擎运行配置
strata-iq3_s.json本次修复加的就是最后那个
sampling块——不写它,引擎默认贪婪:{ "exe": "$HOME/Strata/engine/strata", "args": [ "--pack", "$HOME/Strata-iq3s/packs/iq3_s", "--native", "$HOME/Strata-iq3s/models/IQ3_S/Qwen3.8-Flash-Next-GSQ-RCO-IQ3_S-00001-of-00002.gguf", "--ple-gguf", "$HOME/Strata-iq3s/models/IQ3_S/Qwen3.8-Flash-Next-GSQ-RCO-IQ3_S-00002-of-00002.gguf", "--expert-profile", "$HOME/Strata/data/expert-profile.bin", "--expert-cache", "auto", "--mmap-experts", "--resident-cpu-experts", "--prefill", "auto", "--spec", "4", "--mtp", "$HOME/Strata-iq3s/mtp/rt", "--max-context", "262144", "--kv", "int8", "--kv-resident", "32768", "--spec-min-p", "0.5" ], "cwd": "$HOME/Strata", "tokenizer": "$HOME/Strata-iq3s/packs/iq3_s/tokenizer", "model_name": "qwen3.8-flash-next-iq3_s", "log": "$HOME/Strata/strata-iq3_s.log", "backend": "hip", "host": "127.0.0.1", "port": 8080, "env": { "STRATA_HIPBLASLT_TUNING": "$HOME/Strata/tools/hip/gfx1201-hipblaslt-100200.txt", "STRATA_PREFILL_MMQ": "1", "STRATA_PREFILL_RING": "96", "STRATA_IO_THREADS": "32", "STRATA_HIP_WMMA": "1", "STRATA_SELECT_WMMA": "1" }, "sampling": { "temperature": 0.3 } }几个参数的作用:
--spec 4+--mtp是草稿式投机解码;--expert-cache auto+--resident-cpu-experts让 MoE 专家常驻(GPU 缓存 + 内存页缓存,实测命中 95~97%);--kv int8+--kv-resident 32768是 KV 量化与常驻量;--max-context 262144= 256K。2)systemd 用户服务(端口 8081)
# ~/.config/systemd/user/strata-iq3s.service [Unit] Description=Strata engine (Qwen3.8-Flash-Next IQ3_S) After=network-online.target [Service] Type=simple WorkingDirectory=%h/Strata ExecStart=%h/Strata/.venv/bin/python %h/Strata/serve/server.py \ --engine strata --config %h/Strata/strata-iq3_s.json --port 8081 Restart=no TimeoutStopSec=30 KillMode=mixed MemorySwapMax=0 MemoryMin=24G [Install] WantedBy=default.target
️ 这行 MemoryMin=24G是下限(保内存),别写成MemoryMax:我们踩过——把它塞进带MemoryMax=4G的 cgroup scope 里,模型一涨到 4G 就被内核 OOM 杀掉(看着像"引擎自己崩了",其实是启动方式的问题)。3)启动器(精简版:起 / 停 / 等就绪 / 看指标)
#!/usr/bin/env bash # strata-flash.sh —— 起服务 + 等就绪 + 打印模型与显存 set -uo pipefail SVC=strata-iq3s.service PORT=8081 BASE="http://127.0.0.1:${PORT}" LOG="$HOME/Strata/strata-iq3_s.log" wait_health() { # Strata 加载约 40~60s,别急着判失败 echo -n "→ 等 health" for i in $(seq 1 60); do [ "$(curl -s -m 3 -o /dev/null -w '%{http_code}' "$BASE/health")" = "200" ] \ && { echo " 就绪(约 $((i*2))s)"; return 0; } sleep 2; echo -n "." done echo " ❌ 超时;看日志 $LOG"; return 1 } case "${1:-start}" in start) systemctl --user start "$SVC" && wait_health curl -s -m 5 "$BASE/v1/models" | head -c 300; echo ;; stop) systemctl --user stop "$SVC" ;; restart) systemctl --user stop "$SVC"; "$0" start ;; status) echo -n "服务: "; systemctl --user is-active "$SVC" curl -s -m 5 -o /dev/null -w "health:%{http_code}\n" "$BASE/health" ;; health) curl -s -m 5 "$BASE/metrics" | python3 -c ' import json,sys e=json.load(sys.stdin).get("engine",{}) for k in ("model","version","max_context","kv","kv_resident","expert_slots","spec","vram_free_mib"): if k in e: print(f" {k}: {e[k]}")' ;; logs) tail -f -n 80 "$LOG" ;; *) echo "用法: $0 [start|stop|restart|status|health|logs]" ;; esac单卡机器上记得:这个引擎和别的推理服务(另外的 llama/vLLM)抢同一张卡,启动前先把它们停掉,不然不是端口冲突就是显存不够。
(路径里的
$HOME按自己的目录替换;上游 Strata 自带的run-iq3_s.sh也能起,上面这份是我们这边加了"等就绪 + 健康指标"的日常用版本。)小注:配置里
"port": 8080,实际启动时由服务单元用--port 8081覆盖(同一台机器上 8080 留给别的引擎,方便来回切),别被两个数字绕晕。
最后一句:如果你也撞上 agent 复读,先按上面第 3、5 条把现象量化(滑窗重复率 + 工作区指纹),再去查第 1、2 条的采样。"模型太笨"应该排在最后,不是第一个。
- CPU:AMD Ryzen 7 9700X(8 核 16 线程,最高 5.58 GHz,L3 32 MB),
-
-
-
,
P perter 引用了 此主题
-
-
,
T terry 固定了此主题
-
-
-
,系统 取消固定了此主题
-
,
P Prio 引用了 此主题
