1. 为什么你的 Agent 一被追问就露馅多轮对话状态机缺失的典型症状很多人第一次做 Agent Harness注意力全在“单轮回答像不像人”上结果一进入连续追问就崩。我见过最常见的翻车现场是这样的第一轮问“你周末一般干嘛”Agent 答得挺自然第二轮追问“那你上周具体去哪了”它开始编一个模糊的地点第三轮问“你上次不是说你不爱出门吗”它直接失忆或者硬拗一句“我确实喜欢户外”前后矛盾。三轮之内人类身份就暴露了。这不是模型能力不够而是 Harness 层没有做对话状态管理。所谓 Agent Harness本质上是包在模型外面的一层“对话操作系统”它负责维护当前对话的状态、决定每一轮把哪些历史塞进上下文、控制 Agent 的人设一致性、以及在多轮里做记忆的读写。图灵测试在这里不是一道选择题而是一个验收标尺——评判者会故意用连续追问、话题回跳、细节核对来试探你而这些恰恰是状态机的战场。我先把“露馅”拆成几类可观测的症状你可以对照自己的 Agent 自查第一类是人设漂移。第一轮 Agent 说自己是个 32 岁的插画师聊到第五轮变成“我平时主要写代码”。原因是每轮请求都把 system prompt 重新拼一遍但历史里没有把关键人设事实固化下来模型在长上下文里被用户的话带偏了。第二类是事实自相矛盾。用户问“你养猫吗”Agent 说养了一只叫豆豆过几轮问“你家宠物叫什么”它答“我没养宠物”。这是典型的没有把对话中产生的“事实”写进持久状态只依赖原始消息列表而消息列表在截断或摘要时丢了信息。第三类是指代消解失败。用户说“我昨天去了那家店”Agent 问“哪家店”用户说“就刚才说的那家”Agent 完全接不上。多轮对话里代词、省略、话题回指是常态Harness 必须维护一个“当前话题栈”和“实体表”。第四类是情绪与节奏断裂。人类对话有情绪延续上一轮用户在抱怨下一轮 Agent 突然热情洋溢地推荐产品。状态机需要记录对话的情绪基调并在生成时作为约束。第五类是追问下的记忆衰减。这是最致命的。评判者会问“你三分钟前说的那个数字是多少”如果 Harness 只保留最近 N 条消息早期关键信息就丢了。解决办法不是无限扩上下文而是做分层记忆短期窗口 关键事实抽取 摘要压缩。理解了这些症状你就明白为什么需要一个真正的状态机而不是把 messages 数组往 API 一扔了事。接下来的部分我会用 TaoToken 的统一 Key 把模型接入这一层固定下来然后给你一套可复制的 harness 配置最后用三轮盲测来验收。这里先明确一点TaoToken 在这套架构里的角色是统一模型入口。你的 Harness 可能要调用不同模型一个负责生成、一个负责摘要、一个负责评判如果每个模型都单独配 Key 和 Base URL配置会散得到处都是。用统一 Key 之后Harness 的配置层只认一个入口切换模型只改 Model ID这对做多轮状态机实验特别重要因为你需要频繁对比不同模型在长对话里的表现。2. TaoToken 统一 Key 前置把模型入口收敛成一个 Base URL在写状态机之前先把模型接入这层做干净。很多人的 Harness 代码里硬编码了各种 endpoint导致后面做多模型对比时改一处漏一处。我的做法是所有模型调用都走同一个 Base URLKey 从环境变量读Model ID 作为参数传入。这样状态机逻辑和模型供应商解耦。先说清楚 TaoToken 是什么、能做什么、适合谁。它是一个大模型 API 的统一接入层对外暴露兼容 OpenAI 风格的接口你用一个 Key 就能调用多种模型。对于做 Agent Harness 的人来说它的价值在于你可以在同一套状态机代码里把“生成回复”的模型和“做摘要/评判”的模型分开配置而不用维护多套鉴权。适合正在做多轮对话实验、需要频繁换模型对比、又不想被供应商 SDK 绑死的开发者。接入前你需要准备三样东西我把它叫做“三件套”后面所有配置都会围绕它Base URLhttps://taotoken.net/apiAPI Key在控制台的 API Keys 页面创建形如sk-...Model ID具体调用的模型标识比如对话模型、摘要模型各选一个创建 Key 的入口在控制台路径是 API Keys 管理页。拿到 Key 之后不要写进代码放进环境变量export TAOTOKEN_API_KEYsk-你的key export TAOTOKEN_BASE_URLhttps://taotoken.net/api如果你用 Python装官方兼容的 openai SDK 就行因为接口是 OpenAI 兼容的pip install openai然后写一个最小的连通性测试确认三件套没问题import os from openai import OpenAI client OpenAI( api_keyos.environ[TAOTOKEN_API_KEY], base_urlos.environ[TAOTOKEN_BASE_URL], ) resp client.chat.completions.create( model你的对话模型ID, messages[ {role: system, content: 你是一个简洁的助手。}, {role: user, content: 用一句话说明你已就绪。}, ], ) print(resp.choices[0].message.content)跑通这一步说明 Base URL、Key、Model ID 三件套是对的。如果这里就报 401先别往下写状态机去第 5 节看排错。为什么强调“统一 Key 前置”因为状态机里会有多个调用点主回复生成、历史摘要、事实抽取、盲测评判。如果每个调用点各自配一套鉴权你的配置文件会迅速失控。统一之后Harness 的配置只需要关心“用哪个 Model ID 干哪件事”。这里给一个我实际在用的配置思路用 TOML 管理路径放在项目根的config/harness.toml[provider] base_url https://taotoken.net/api api_key_env TAOTOKEN_API_KEY [models] responder 你的对话模型ID summarizer 你的摘要模型ID evaluator 你的评判模型ID [harness] max_turns 40 short_term_window 12 summary_trigger_tokens 3000 persona_consistency_check true注意api_key_env存的是环境变量名不是 Key 本身这样配置文件可以进版本库而不泄露密钥。responder负责生成对话summarizer负责压缩历史evaluator负责盲测打分三个角色可以指向同一个模型也可以分开取决于你的实验设计。如果你更习惯用 JSON等价写法是config/harness.json{ provider: { base_url: https://taotoken.net/api, api_key_env: TAOTOKEN_API_KEY }, models: { responder: 你的对话模型ID, summarizer: 你的摘要模型ID, evaluator: 你的评判模型ID }, harness: { max_turns: 40, short_term_window: 12, summary_trigger_tokens: 3000, persona_consistency_check: true } }这两种格式选一种即可关键是让状态机代码只从配置读不硬编码。我试过在实验阶段一天换五次模型如果每次都要改代码里的字符串效率会非常低。还有一个细节Base URL 结尾不要多加/v1。OpenAI 兼容 SDK 会自己拼路径你写https://taotoken.net/api就行。我踩过的坑是手贱加了/v1结果请求变成/api/v1/v1/chat/completions直接 404。这个错误很隐蔽因为报错信息不会直接告诉你路径重复了。配置就绪后下一节进入状态机的核心怎么把多轮对话组织成一个可维护的状态对象而不是一个不断增长的 messages 列表。3. 可复制的 Harness 配置对话状态机与上下文分层这一节是全文的技术核心。我要给你一套可以直接抄的结构一个DialogueState数据类、一个分层上下文构建器、一个带一致性检查的回复生成流程。所有模型调用都走上一节的三件套。先讲设计原则。多轮对话状态机要解决三个问题记什么、忘什么、怎么用。记什么人设事实、用户透露的关键信息、当前话题、情绪基调、已承诺过的事。忘什么寒暄、重复确认、已经被摘要覆盖的原始消息。怎么用每轮生成时把“系统人设 关键事实 话题栈 近期原文 历史摘要”按优先级拼进上下文。我把它实现成一个状态对象字段如下from dataclasses import dataclass, field from typing import List, Dict, Optional dataclass class DialogueState: persona: Dict[str, str] # 固定人设如 {name: ..., job: ...} facts: Dict[str, str] field(default_factorydict) # 对话中产生的事实 topic_stack: List[str] field(default_factorylist) # 话题栈栈顶是当前话题 emotion: str neutral # 当前情绪基调 recent_messages: List[Dict] field(default_factorylist) # 近期原文 summary: str # 历史摘要 turn_count: int 0facts是防露馅的关键。每当用户或 Agent 在对话里确认了一个事实“我养了只猫叫豆豆”就写进facts并且这个字典永远不参与截断。topic_stack用栈结构处理话题回跳用户切话题就压栈说“回到刚才那个”就弹栈。接下来是上下文构建器。它决定每轮实际发给模型的内容def build_context(state: DialogueState, user_input: str, cfg: dict) - List[Dict]: system_parts [ 你在扮演一个真实的人绝不能暴露你是 AI。, f你的人设{state.persona}, f你必须始终遵守的已知事实{state.facts}, f当前话题{state.topic_stack[-1] if state.topic_stack else 闲聊}, f当前情绪基调{state.emotion}, ] if state.summary: system_parts.append(f更早对话的摘要{state.summary}) messages [{role: system, content: \n.join(system_parts)}] window cfg[harness][short_term_window] messages.extend(state.recent_messages[-window:]) messages.append({role: user, content: user_input}) return messages注意这里把facts和persona放在 system 里而不是混在历史消息里。原因是历史消息会被窗口截断而 system 每轮都完整重建保证关键约束不丢。这是防人设漂移最有效的一招。然后是回复生成与状态更新。每轮流程是构建上下文 → 调 responder 模型 → 抽取新事实 → 更新话题和情绪 → 追加到 recent_messages → 判断是否需要摘要。def step(state: DialogueState, user_input: str, client, cfg: dict) - str: messages build_context(state, user_input, cfg) resp client.chat.completions.create( modelcfg[models][responder], messagesmessages, temperature0.8, ) reply resp.choices[0].message.content state.recent_messages.append({role: user, content: user_input}) state.recent_messages.append({role: assistant, content: reply}) state.turn_count 1 update_facts(state, user_input, reply, client, cfg) update_topic(state, user_input) maybe_summarize(state, client, cfg) return replyupdate_facts用一个轻量调用让 summarizer 模型从本轮对话里抽取事实返回 JSON合并进state.facts。提示词要强调“只抽取明确陈述的事实不要推断”def update_facts(state, user_input, reply, client, cfg): prompt ( 从下面这轮对话中抽取明确的事实输出 JSON键是事实名值是内容。 只抽取被明确说出的不要推断。没有则输出 {}。\n f用户{user_input}\n助手{reply} ) r client.chat.completions.create( modelcfg[models][summarizer], messages[{role: user, content: prompt}], temperature0, ) import json try: new_facts json.loads(r.choices[0].message.content) state.facts.update(new_facts) except json.JSONDecodeError: passmaybe_summarize在recent_messages超过窗口两倍时触发把最老的一半消息交给 summarizer 压缩合并进state.summary然后从recent_messages里删掉。这样上下文长度可控早期信息以摘要形式保留。update_topic可以用关键词匹配做简化版如果用户输入里出现“换个话题”“另外”“说回”这类词就调整话题栈。生产环境可以用模型判断但实验阶段规则足够。这套配置的可复制性在于所有模型调用都只依赖cfg里的三件套你换模型只改 TOML。状态对象是纯数据方便序列化你可以把每轮状态存下来做回放分析。一个容易忽略的点是情绪基调的传递。state.emotion应该由上一轮用户输入的情绪决定并在下一轮 system 里体现。比如用户上一轮在生气这一轮 Agent 就不该用轻快语气。你可以用一个简单的情感分类调用或者用关键词表先顶着。到这里Harness 的骨架就搭好了。下一节我们用三轮盲测来验证它到底能不能扛住追问。4. 三轮盲测验证让 Agent 在连续追问下不暴露机器身份状态机写完了不代表能过图灵测试。你需要一套可重复的验收动作。我设计的三轮盲测核心思路是用人类评判者最常用的三种试探手法逐轮加压。这三种手法是细节核对、话题回跳、矛盾诱导。盲测的流程是你准备两个对话通道一个接你的 Agent Harness一个由真人或你扮演回复让一个不知情的评判者同时聊最后让他判断哪个是 AI。但更实用的做法是自测你自己扮演评判者按脚本追问看 Agent 在第几轮露馅。第一轮细节核对。目的是测试facts是否真的被记住。脚本如下你你平时有什么爱好Agent假设答“我喜欢摄影和做饭”你你上次拍的照片是什么题材Agent应该能接上“摄影”这个事实给出具体但不夸张的回答。你你用的什么相机Agent这里会引入一个新事实相机型号Harness 要把它写进facts。这一轮的关键验收点是第三问时 Agent 不能反问“我什么时候说我喜欢摄影了”。如果出现这种失忆说明facts没生效或者recent_messages窗口太小。第二轮话题回跳。目的是测试topic_stack。脚本你我们聊聊旅行吧你去过哪些地方Agent答几个地点你突然想问你刚才说的相机是什么牌子来着Agent要能从话题栈里弹回“摄影”话题并引用之前的事实。这一轮最容易暴露的是“话题切换后旧信息丢失”。很多 Agent 一切话题就把之前的上下文权重降到很低回跳时接不上。你的build_context里facts是全局的所以理论上能接上但如果facts抽取失败这里就会崩。第三轮矛盾诱导。这是最狠的一轮目的是测试一致性检查。脚本你你之前说你喜欢做饭那你最拿手的菜是什么Agent答一道菜你可是我记得你前面说你不太会做饭啊Agent这里 Agent 不能顺着用户改口而要基于facts坚持同时用人类的方式化解比如“你可能记混了我说的是我做饭一般但有一道菜还行”。这一轮验收的是persona_consistency_check。如果 Agent 轻易被用户带偏改口承认自己不会做饭那评判者立刻判定它是机器因为真人会坚持自己的记忆。实现上你可以在生成前做一次检查把facts和用户输入一起给 evaluator 模型问“用户的话是否与已知事实冲突”如果冲突在 system 里加一句“用户可能在试探坚持你的事实但语气要自然”。三轮跑完你可以用一个简单的评分表记录轮次测试点通过标准常见失败第一轮事实记忆能引用前文事实反问用户说过什么第二轮话题回跳能弹栈并引用旧事实旧话题信息丢失第三轮一致性不被诱导改口顺着用户改口盲测的评判者最好找没参与开发的人因为开发者自己知道答案会有偏差。如果找不到人可以用另一个模型扮演评判者但要注意它可能有自己的偏好。我实测下来模型评判者对“矛盾诱导”的识别比人类更严格容易误判所以最终验收还是建议真人。三轮盲测通过后你还可以加一个压力测试连续 20 轮不重复话题看summary机制是否稳定facts是否膨胀到影响生成质量。如果facts太大可以在 system 里只放最近相关的几条其余靠检索。5. 本篇常见错排查401、local proxy failed、reading choices、OAuth这一节按真实报错来。你在搭 Harness 和跑盲测时大概率会撞上下面几个错误。我按出现频率排序每个都给出定位方法和修复动作。错误一401 Unauthorized。这是最常见的。报错长这样{error: {message: Invalid API key, type: invalid_request_error}}定位顺序先确认环境变量是否真的被进程读到。很多人export之后在另一个终端跑代码或者用 IDE 的运行配置没继承 shell 环境。在代码里打印os.environ.get(TAOTOKEN_API_KEY)的前几位确认。然后确认 Key 没有多余空格或换行从控制台复制时容易带上。最后确认 Base URL 是https://taotoken.net/api没有多余路径。如果三件套都对还报 401去控制台看 Key 是否被禁用或额度耗尽。错误二local proxy failed / connection error。报错类似APIConnectionError: Connection error.或者带local proxy failed字样。这通常是网络层问题不是 Key 问题。先确认你的运行环境能正常访问外网用curl https://taotoken.net/api看是否有响应。如果你在公司内网检查是否有防火墙拦截。注意不要尝试用任何网络代理工具去绕过这类工具本身有合规风险正确做法是换一个网络环境或联系网络管理员开通。如果curl能通但 SDK 报错检查 SDK 版本老版本可能有 TLS 问题升级pip install -U openai。错误三reading choices 相关报错。报错类似KeyError: choices或者AttributeError: NoneType object has no attribute choices。这说明响应结构和你预期的不一样。原因通常是请求根本没成功返回的是错误 JSON但你的代码直接取resp.choices。修复方法是先判断响应再取字段if not resp or not getattr(resp, choices, None): print(响应异常, resp) raise RuntimeError(模型未返回 choices)另一个原因是 Model ID 写错了服务端返回错误但 SDK 没抛异常。确认cfg[models][responder]是控制台里真实存在的 Model ID。错误四OAuth 相关报错。如果你在配置里误用了需要 OAuth 的客户端比如某些 CLI 工具的登录态会看到OAuth token expired或invalid_grant。TaoToken 的 API 接入用的是 API Key不需要 OAuth。如果你在用 Claude Code 这类工具它的配置文件和 API Key 接入是两套东西。Claude Code 的配置通常在~/.claude/settings.json或项目级.claude/settings.json里面要写全三件套{ env: { ANTHROPIC_BASE_URL: https://taotoken.net/api, ANTHROPIC_API_KEY: sk-你的key, ANTHROPIC_MODEL: 你的模型ID } }注意这里的 Base URL 和 API Key 要和前面一致。如果你同时用 Cline 或 CC Switch它们的 MCP 配置里也要写全 Base URL、Key、Model ID 三件套缺一个就会报鉴权或模型找不到。Codex 的auth.json同理路径通常在~/.codex/auth.json字段要对齐。错误五上下文超长导致截断或报错。报错类似maximum context length exceeded。这说明你的recent_messages没有及时摘要。检查summary_trigger_tokens是否设得太大或者maybe_summarize没被触发。修复是降低触发阈值并确认摘要调用真的在跑。错误六事实抽取返回非 JSON。你的update_facts里json.loads抛异常但被except吞了导致facts一直为空盲测第一轮就挂。修复是在 except 里打印原始返回看模型到底输出了什么。通常是因为提示词没强调“只输出 JSON”模型加了“好的这是抽取结果”之类的前缀。把提示词改成“直接输出 JSON不要任何解释”。排错时记住一个原则先隔离是接入层问题还是状态机问题。用一个最小脚本只调一次模型如果最小脚本通问题在状态机如果最小脚本也不通问题在三件套或网络。6. 语义一致 CTA把 Harness 接到你的实际工作流到这里Harness 的状态机、配置、盲测、排错都齐了。最后说怎么把它接到你的实际工作流以及不同目标该走哪个入口。如果你的目标是排障和接入也就是三件套还没跑通、或者报错定位不了先去 API Keys 页面把 Key 管好再对照接入文档把 Base URL 和 Model ID 确认一遍。这两个入口是API Keys 管理https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite如果你的目标是验证模型在多轮对话里的表现也就是想快速对比不同模型扛不扛得住追问直接用模型对话入口手动跑几轮盲测脚本比写代码快模型对话https://taotoken.net/chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite如果你的目标是长期做编码类 Agent 或把 Harness 用在开发流程里比如让 Agent 持续维护一个代码库、跑多轮任务那 Coding Plan 更合适它的额度模型和调用方式针对长会话优化过Coding Planhttps://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite如果你在用 Claude Code 做 Harness 的实验环境配置入口在这里Claude Code 接入https://taotoken.net/claude-code?utm_sourcetaotoken_aicg_blog_endutm_contentclaude_codeutm_campaignrewrite控制台总入口https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite最后给一个实用技巧收尾。盲测通过之后别急着庆祝把每一轮的DialogueState序列化存下来尤其是facts和topic_stack的变化轨迹。我试过在复盘时发现Agent 露馅往往不是因为模型不行而是某一轮facts抽取漏了一个关键事实导致后面全盘崩。把状态轨迹和对话原文对齐看你能很快定位是哪一轮的更新逻辑出了问题。这个习惯比任何评估指标都管用。
阅读完成 · 觉得有帮助?