资讯详情

破解Agent“答完不结束”:DeepSeek Harness停止判定实践指南

📅 2026/9/28 21:43:51 | 华诺云谱 👁 阅读
破解Agent“答完不结束”:DeepSeek Harness停止判定实践指南
做 Agent 最常遇到的一个诡异现象日志里模型已经吐出一整段有头有尾的回答界面上的任务却还挂在“运行中”又过了几秒直接报Execution terminated due to error。我刚开始把 DeepSeek 接到自己的 Harness 里跑 Agent 时也被这个问题折磨过明明回答都出来了Agent 为什么不结束这不是模型抽风也不是 DeepSeek 的 bug而是我们对“结束”这件事的定义和 Harness 的循环机制没有对齐。这篇文章是“拆开 DeepSeek Harness”系列的第三篇聚焦最容易被忽略的终止判定问题。我会先讲清楚 Agent 循环里“模型回答”和“任务完成”的本质区别再拆五种常见的“答完不结束”现场最后给出一套可以直接抄走的停止判定逻辑和代码。适合自己写 Agent 框架的人、把 DeepSeek API 接进 Codex / Claude Code 这类工具里的开发者以及所有被“答完不结束”折磨过的人。1. 模型说“我答完了”跟 Agent 真正结束是两码事1.1 Agent 的一次任务是多轮循环不是一个 completion很多从普通聊天 API 转过来的人天然会把“模型生成完一段文本”当成“任务结束”。在单轮对话里确实如此你发一个 promptDeepSeek 返回一个 content就完事了。但在 Agent 场景里模型每次返回的都只是新一轮的“想法或动作”不是任务终点。一个典型的 Agent 循环长这样把用户问题连同历史消息发给模型模型返回一条消息里面可能只有自然语言也可能带tool_calls如果带tool_callsHarness 执行对应工具把结果拼回上下文再次调用模型让模型基于工具结果继续思考直到某次模型返回没有tool_calls且内容达到可交付状态循环才结束。关键在于第 5 步的“直到”到底怎么判定。模型完全可能在中间某一轮说“好那我先把结论整理一下”但这句话只是它内部的思考过程它同时还在tool_calls里请求查询另一个接口。这时候你用人的眼睛看会觉得“它已经答完了吧”可 Harness 看到的是“还有工具要执行”于是继续循环。你以为是模型不结束其实是它本来就没打算结束。我在本地跑 DeepSeek 的时候经常把 assistant 消息原样打印出来发现 content 和 tool_calls 是同时存在的。这个很容易骗到人content是给用户看的话tool_calls是给系统看的指令两者互不排斥。只要tool_calls数组非空Harness 就必须把工具跑了否则下一轮模型的上下文里就缺一段关键信息。1.2 Harness 在中间扮演什么角色聊 Agent 有个很大的误区以为模型越聪明框架就越省事。实际上模型只是“大脑”Harness 才是“手脚加项目经理”。我这里说的 Harness泛指套在模型外层的编排脚手架不管你是用 LangChain、自研 loop还是给 Codex 加 DeepSeek 后端的适配层它至少要管四件事上下文管理哪些消息该保留哪些工具结果要压缩工具注册与调度把模型请求的tool_name映射到真实函数循环控制什么时候该继续调模型什么时候该 break终止判定判断这次 Agent 运行是正常结束、异常结束还是需要人工介入。普通聊天接口只负责“生成”Agent 框架才负责“结束”。DeepSeek 提供的是通用的 chat/completions 接口没有帮你托管一整条 agentic loop所以停止逻辑 100% 要靠上层 Harness 来写。这就是为什么同一个模型放在不同框架里表现天差地别不是模型变笨了是框架的验收标准不一样。我习惯把“结束”理解为一次验收。模型是员工Harness 是项目经理。员工说“我做完了”不算数只有项目经理对着验收清单打了个勾任务才算真正结束。清单上一般有这几项本轮没有待执行的 tool_calls模型产出了面向用户的最终内容可选的结构化结束标记比如final_answer循环次数和预算没有超限或超限时走了强停逻辑。很多“答完不结束”的 bug本质就是清单里的某一项没被满足或者框架把验收条件搞错了。2. 五种最常见的“答完不结束”现场2.1 模型最后一条消息里还挂着 tool_calls这个是最容易踩的坑。你调用 DeepSeek API返回的 message 里 content 已经写得很完整了比如{ role: assistant, content: 我已经找到相关资料正在为你整理最终结论……, tool_calls: [ { id: call_123, function: { name: search_web, arguments: {\query\: \DeepSeek R1 蒸馏\} } } ] }很多新手框架的停止条件写的是“只要 content 非空就结束”但更合理的判断必须是“tool_calls为空才考虑结束”。上面这种情况content 非空tool_calls也非空Harness 看到的是还需要执行一次搜索当然不会退出。于是你看到的现象就是回答已经出来了Agent 又跑去搜了一轮甚至好几轮。处理方式倒不复杂把“生成内容”和“决定动作”两件事拆开。只要 assistant 消息里带了tool_calls就无条件先执行工具然后把工具结果随同“你刚才提到正在整理请基于新结果继续完成最终回答”一起送回模型。等哪一轮tool_calls彻底为空再谈是否结束。我自己的经验是不要信任“content 已经看起来像答案”这种直觉判断程序只看结构。你可以在打印日志时把content和tool_calls分行显示跑几个真实任务很快就会发现哪些轮次是被工具调用“拖住”的。2.2 结束条件只看结构化标记不看自然语言另一种情况完全反过来Harness 强制模型以结构化标记结尾比如规定“如果你回答完了必须输出{action: final_answer, content: ...}”。模型在大部分时候会遵守但一旦上下文复杂、工具结果很多DeepSeek 可能“忘记”这个约定直接输出了自然语言好的我已经完成分析了结果就是以上内容如果还有问题可以继续问我。这句在人类看来就是明确的结束信号但 Harness 只认结构化标记发现没有final_answer就以为模型还在对话中继续调用工具或者空转直到超时。这里我不想把锅全甩给框架。提示词工程能缓解但不要指望 100%。更稳的做法是把结构化标记当成“加分项”而不是“唯一通行证”。也就是先判断硬性条件——本轮没有tool_calls、content 非空、消息末尾没有出现“我接下来需要执行”之类的话——如果满足就进入“候选结束”状态。如果这时确实拿到了final_answer标记直接结束没拿到可以再给一次轻量校验让另一个更小、更便宜的模型判断这句话是不是“可以交付给用户的最终回答”。实际跑下来这种“硬条件 软校验”的组合比单纯等标记稳很多。特别是 DeepSeek 这类模型在长上下文里指令遵循能力会波动你不能在关键路径上赌它一定按格式输出。2.3 max_steps 设得太大框架还在“给机会”还有一种很冤的情况模型其实第 5 轮就已经把问题答完了但你的 Harness 把max_steps设成了 20而且没有任何“提前结束”逻辑于是一口气继续跑。模型在后面几轮因为没有新指令开始重复之前的结论甚至为了“找事情做”又发起工具调用循环越跑越偏。这不是模型的问题是停止策略缺少“收敛判断”。我把这种状态叫“敷衍式续跑”模型面对同样的上下文没有新信息可处理就只能重复或自我修正。你要是看日志会发现好几轮的 content 几乎一样工具调用模式也高度相似。解决思路有两个设置max_steps的上限只是兜底不要依赖它作为正常结束方式加一条“连续无新增信息就提前终止”的规则。比如连续两轮没有新的tool_calls且 assistant 消息的 content 相似度超过某个阈值就判定为“模型已经收敛”直接把最后一轮有效回答作为最终结果返回。相似度不用上多复杂的模型用简单的文本哈希或者 Jaccard 相似度都能凑合。对于更严格的场景可以每轮把 assistant 消息做 embedding算一下与上一轮的余弦相似度超过 0.95 就直接 break。这套办法我在跑 DeepSeek 长文本任务时很常用。2.4 流式响应里最后一帧没拿到或拼接出错把 DeepSeek API 配成streamTrue之后Agent 框架要自己把多个 chunk 拼成完整消息。问题往往出在这几个地方最后一个 chunk 里带有finish_reason: stop但网络抖动把它丢了框架拼接tool_calls时漏掉了某个分片的index导致工具参数不完整流式返回里content已经打完但finish_reason一直没出现Harness 还傻等。模型层面它已经“回答完了”但流式协议层面还没画上句号Agent 自然不肯结束。最直接的兜底是加“空闲超时”最近 N 秒没有新 chunk就认为流已经结束哪怕没拿到finish_reason也强制收尾。我一般设 30 秒空闲太小容易误杀慢网络太大又会让用户干等。另外流式场景里工具调用的解析要特别小心。OpenAI 兼容接口的流式格式会把tool_calls按 index 分片返回比如第一次只有index0的 name第二次才有index0的 arguments 片段。如果框架只是简单覆盖而不是按 index 拼接就会出现工具名对了、参数被截断。工具参数无效模型就会反复纠正自己看起来“永远不结束”。2.5 你以为的“最终回答”其实只是工具结果汇总有时候模型产出的内容本身没问题但那只是对工具结果的转述还不是完整交付。比如用户问“对比方案 A 和 B”DeepSeek 调用了一个数据库工具拿到 A 的数据然后直接输出了“A 的数据如下”之后就闭嘴了。人在日志里看到“数据如下”觉得已经答完但 B 的数据还没查呢。Harness 如果这时候因为“没有 tool_calls”就结束其实是一次错误的早停。这种情况更像“假结束”模型自己以为信息够了实际任务还没完成。解决办法是在 prompt 里写清楚交付标准同时在结束判定里加入“结果完整性校验”的钩子如果发现用户问题里明确提到了多个实体、多个维度而当前答案只覆盖了一部分就让模型继续。当然这个校验很难用规则穷举我的方案是让模型在最终输出前先写一句“任务完成 checklist”比如[x] 已获取方案 A 数据[ ] 已获取方案 B 数据[ ] 已给出对比结论Harness 只在 checklist 全部打勾后才放行。这一招对多步信息检索类任务非常有用能大幅减少“答非所问但自我感觉良好”的情况。3. 实操给 Harness 加一套可靠的停止判定3.1 先能复现把循环日志打全排查“答完不结束”的第一步永远是先完整记录循环里发生了什么而不是只打印最终结果。我建议在每一轮循环开始前输出以下几样东西step当前第几轮last_message_role上一轮是谁在说话last_message_has_tool_calls有没有待执行工具finish_reasonAPI 返回的结束原因content_len和tool_calls_count别只看有没有内容要看长度和数量accumulated_tokens累计 token方便判断是不是预算问题elapsed_seconds从任务开始到现在跑了多久。日志格式不用讲究能看懂就行关键是每轮都有后面横着对比才能看出“模型到底哪一轮开始说结束又为什么没被放行”。这是我常用的一个简化版循环骨架重点看注释里的判定逻辑import time def run_agent(client, messages, tools, max_steps10): step 0 last_content while step max_steps: step 1 print(f[step {step}] 发送消息 {len(messages)} 条) resp client.chat.completions.create( modeldeepseek-chat, messagesmessages, toolstools, streamFalse, ) msg resp.choices[0].message tool_calls getattr(msg, tool_calls, None) or [] finish_reason resp.choices[0].finish_reason content msg.content or print(f finish_reason{finish_reason}, content_len{len(content)}, tool_calls{len(tool_calls)}) # 先把 assistant 消息存进上下文这是必须的 messages.append({role: assistant, content: content, tool_calls: tool_calls}) # noqa # 有 tool_calls 就执行并继续循环 if tool_calls: for tc in tool_calls: tool_result dispatch_tool(tc.function.name, tc.function.arguments) messages.append({ role: tool, tool_call_id: tc.id, content: tool_result, }) # 这轮绝对不能结束 continue # 到这里说明没有 tool_calls进入“候选结束”分支 if is_final_answer(content): print(f[step {step}] 检测到最终回答正常结束) return content # 重复内容收敛保护 if content and similar(content, last_content) 0.95: print(f[step {step}] 内容与上一轮高度重复提前结束) return content last_content content # 到达 max_steps 只能算“超时结束”不算正常完成 print(达到 max_steps强制结束) return last_content这段代码不是完整生产实现但把核心判定顺序写清楚了先处理tool_calls再判断内容是否结束再做重复保护最后才是max_steps兜底。3.2 判定“真完结”的四个条件我最后沉淀下来的停止判定基本可以浓缩成四个条件。它们有优先级从上往下依次判断本轮 assistant 消息里没有待执行的tool_callscontent非空并且不包含“继续调用工具”的明确意图要么带有结构化结束标记要么通过了轻量完整性校验无论上面怎么判断循环次数没超限空闲时间没超时。前两个是硬条件不满足就一定继续。第三个是软条件需要结合你的任务类型决定严格程度。第四个是安全网防止前面所有逻辑都失效时任务被永久挂起。有人会问为什么不直接用finish_reason stop作为结束条件原因是finish_reason只能说明“模型生成了多少内容”不能说明“任务是否完成”。模型可能因为上下文长度截断而返回length也可能在需要继续工具调用时返回stop但因为解析问题丢了tool_calls。过分依赖它就会把结束主动权完全交给模型而模型恰恰是最需要被约束的那个。3.3 一次改造后的核心代码示例下面给出更完整的判定函数专门负责“这一轮返回能不能作为最终答案”。把它从主循环里抽出来方便单独测试。def should_stop(assistant_msg, task_checklist): 返回 (是否结束, 原因) tool_calls getattr(assistant_msg, tool_calls, None) or [] if tool_calls: return False, has_tool_calls content assistant_msg.content or if not content.strip(): return False, empty_content # 结构化结束标记常见于 JSON 输出模式 if isinstance(content, str) and action: final_answer in content: return True, final_marker # 明确表示还要继续动作的不结束 if any(k in content for k in [我需要再查, 调用工具, 接下来执行, 正在搜索]): return False, continue_intent # 完整性问题checklist 未完成让模型继续 if task_checklist and not task_checklist.is_done(): return False, checklist_incomplete return True, natural_final这里我特意把“结构化标记”和“任务完整性”分开。很多 Agent 框架只认结构化标记导致自然语言结尾被误判也有的框架完全不管任务完整性模型说啥都放行。两边的坑我都踩过拆开处理是最稳的。配套的主循环里再加一个“候选结束确认”步骤当should_stop返回True时不立即结束而是再调用一次小模型让它在 20 token 内回答“这段内容是否已经完整回答了用户问题”。如果小模型说“是”才真正结束。这个额外调用成本很低但对降低“答非所问”特别有效。4. 常见问题与排查技巧实录4.1 问题速查表下面这几类问题是我在接 DeepSeek 跑 Harness 时最常遇到的写成表格方便直接对照。现象可能原因处理方式模型已输出完整回答任务仍显示运行中assistant 消息里带tool_calls框架还在执行工具打印tool_calls长度确认是否漏看结构字段回答看起来正常最后报Execution terminated due to error工具执行异常错误信息被塞回上下文模型反复重试给单步工具加重试上限重试 2 次后直接强跳finish_reason为stop但任务还继续跑框架错误地把stop当成了“继续”信号检查框架的逻辑分支stop本身不等于最终回答模型连续多轮输出相似内容max_steps太大且缺少收敛检测加相似度判断连续两轮高相似就提前终止流式模式下回答已结束但任务一直挂起最后一个 chunk 丢了finish_reason加空闲超时30 秒无新 chunk 强制收尾工具调用参数总是被截断流式分片index拼接错误按index累积拼接name和arguments模型总是不按约定输出final_answer标记长上下文导致指令遵循下降不要死等标记用“硬条件 软校验”判断日志里模型说“已完成”但 B 数据没查模型自我感觉良好任务实际没完成在提示词里要求输出任务 checklist逐项打勾4.2 我的几条独家调试心得先说结论不要迷信finish_reason也不要迷信模型自己说“我结束了”。我自己跑下来比较可靠的组合是“无tool_calls 内容完整 可选小模型校验”。第二条经验如果你用的是 DeepSeek 的流式接口一定要在本地把原始 chunk 落盘一次特别是tool_calls相关的 chunk。我遇到过太多次“看起来是随机 bug”的结果最后发现都是拼接逻辑少了半个分片。落盘之后直接把原始响应跟拼接后的消息做 diff问题立刻现形。第三条经验把“人工确认”也作为结束条件之一。很多 Agent 框架只有自动结束没有“暂停等用户确认”的状态。我在 Harness 里加了一个waiting_for_user分支模型给出候选答案后如果置信度不高就先返回一个“确认卡片”给用户用户点“继续”才会进入下一轮。这个设计对减少幻觉输出很有帮助。还有一条很实用的经验上下文越长模型越容易“假装结束”。当累计消息太多后面的工具结果会被前面的内容稀释模型可能只看到局部信息就急着下结论。我通常会把超过一定长度的工具结果做摘要而不是完整塞进上下文。这不只是为了省 token也是为了减少“答完不结束”和“假结束”的概率。最后再分享一个小技巧给每个任务加一个全局唯一的task_id每一轮的日志都带上这个 id。排查问题的时候直接按 id 拉出完整轨迹比在无标识日志里大海捞针痛快多了。我现在的 Harness 里所有日志都走结构化输出至少包含task_id, step, event, duration_ms四个字段。这套习惯帮我省下大量排查时间几乎成了我做 Agent 开发的默认配置。Agent 结束问题说到底不是模型理解力问题而是工程闭环问题。你把“停止判定”当作和“工具调度”一样重要的一等公民来设计把模型当成一个会偷懒也会啰嗦的同事提前定好验收标准和兜底策略那些看起来很玄学的“为什么还不结束”最后都会变成可以复现、可以修复的确定性逻辑。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑