这次给 Aido Planner 做 harness 改造,起点是一个很朴素的观察:每次生成计划前,backend 要往 context 里塞 25 个字段,跑 22 + N 次查询(N 是用户项目数),其中一张任务表在一次请求里被读了 9 遍。用真实日志抽样统计,context JSON 中位数 6,449 字符,渲染成 prompt 后中位数 15,574 字符,估算下来中位数 6,349 tokens,峰值能到 11,485。而这些数据里,接近一半根本不参与最终决策。
但如果只写“我做了 tool calling 改造”,这篇记录就没什么意思了。真正值得写下来的,是这次改造里我三次以为自己想对了,又被实测数据推回去重新想的过程。
起点:装配层不知道这次规划需要什么
先说清楚问题出在哪。当前 Planner 的信息供给方式是全量装配:不管用户说的是“帮我排今天的活”还是“下周要交方案,帮我倒推准备”,backend 都把同一份 25 个 key 的 context 一次性塞给模型。
具体到数字,最扎眼的几个浪费点是这样的:
past_week_tasks占最大样本 46.2%(9,342 字符),但 prompt 里自己写着“它们默认不是直接候选池”——查它的 16 条数据里有 9 条状态是已完成或归档,后端别的路径都会过滤,只有这一条查询路径没过滤daily_tasks(18.2%)、carry_over_tasks(9.5%)、yesterday_final_plan(6.2%)加起来接近 34%,全是和别的字段重复或者已经被新字段完全覆盖的死字段- 8 个 key 在实测样本里是空数组,但仍然各占一个 prompt 小节标题
- 同一句用户输入会在 context 里出现三次,还被渲染进 prompt 两次
问题不是信息太多,而是:
装配层没有判断依据,所以只能把所有可能相关的都带上。
这正是 tool calling 要解决的形态——让需要信息的一方自己决定要什么,而不是让上游猜。而且这不只是当前数据量下能不能扛住的问题:past_week_tasks 这类字段的体积和用户活跃度成正比,用得越久越大,但它对当天排期的价值不变。上下文窗口是硬约束,真实活跃用户迟早会撞上截断或报错,而且没有优雅降级的空间。
现有代码比文档描述的更薄
盘点现有代码后发现一个挺意外的事实:Aido 的自研 runtime 一共只有四个文件——context.py(69 行)、errors.py(77 行)、llm_client.py(283 行)、structured_output.py(111 行)。最高抽象是 StructuredOutputRunner.run():一次 chat() 加一次解析,没有循环、没有分支。llm_client.py 里零 tools / tool_call 相关代码,四个 Pydantic 模型全是 extra="forbid",LLMRole 里连 tool 这个角色都没有。
LangGraph 那层更是只是个壳:graph.py 全文 26 行,START → chat_node → json_node → END,线性、无条件边、无循环、无工具节点。真正的业务逻辑全在 nodes.py 714 行手写归一化函数里——来源枚举校验、项目字段 grounding、日期归一化、fallback 逻辑,这些才是防幻觉的核心。
也就是说,这次改造不是替换一个编排框架,而是补上一个从来不存在的能力层——tool 注册、调度、循环控制、预算约束,基本都要新建。好消息是底层可用:AsyncOpenAI + DashScope OpenAI 兼容端点,参数构造是开放式的,SDK 层面支持 tool calling,只是 runtime 没有暴露出来。
目标架构长这样:新增一个 Harness 层,核心是 AgentLoop(调模型 → 判断有没有 tool_calls → 执行工具、追加结果 → 再调,直到收敛)+ ToolRegistry(工具注册调度)+ LoopBudget(轮次、调用数、token 硬上限)。原来 nodes.py 里那 714 行归一化逻辑,只有两个函数真的依赖 LangGraph,剩下 27 个函数全是纯函数,所以最后没有做“迁移”,而是直接抽成 normalizers.py 让两条链路共用——用同一份测试跑抽取前后的输出,逐字节 diff 完全一致,这比逐个断言更能证明行为没变。
第一期只设计了 5 个工具:query_tasks_by_range(替代 past/next week tasks)、query_project_tasks(替代项目候选的任务部分)、query_templates、query_long_term_plan、query_preference_memory。工具的 description 里写了两条硬约束,而且专门有测试守着这两条:一是明确告诉模型“查到的历史任务不是候选池,不要直接当作今天要排的项”;二是“project_id 必须来自上下文中已有的项目候选,不要凭空构造”。这两条约束不是可选的润色,是防止模型把历史任务当成今日候选、或者编造不存在的 project_id 导致越权查询。
第一次预期落空:真实账号一测,总 token 不降反升
前两轮实验用的是测试账号,历史任务是空的,工具调用总是查到空结果,数据自然好看:context token 降了 87-89%,延迟没有恶化反而更快,工具调用行为 9/9 准确,编造字段 0。
我当时觉得这事基本成了。
第三轮换成真实账号(71 个任务、25 条近期任务、2 个项目共 11 条未完成任务,v1 context 6,148 tokens,很接近生产中位数 6,349)之后,数据翻了个脸:
装配层传输的 context token 确实降了 82-89%,延迟恶化 15-29%(在阈值内),但总 LLM token 反而从 v1 的 6.1K 涨到了 v3 的 18-20K。
这跟“按需提取应该省 token”的直觉完全相反。查下来原因很简单:多轮调用意味着 planner 那 345 行 prompt 被重复计费了几次——省了单轮里的冗余字段,却多付了几次完整 prompt 的钱。
这个发现没有让我推翻整个方案,因为架构层面的必要性(装配层该不该替用户猜测需要什么)不取决于这一轮的 token 数字。但它把“优化 prompt 体积”从一个可选项,直接变成了下一阶段的最高优先级。
第二次修正:我自己先报错了一次数字
后来复盘时发现,我上一轮报告“18-20K 高于 v1 的 6.1K,不达标”这个对比本身就是错的——18-20K 是 usage.total_tokens 按轮累加的 input + output,6.1K 只是 v1 的 context 字符估算,连 prompt 模板都没算进去,更没算 output。口径不同,直接比就是在制造一个假结论。
拆开重算之后,input 是 9,881,output 是 2,235——output 这部分是产出卡片的必要成本,砍不掉。真正能砍的是 input 里被重复计费的固定 prompt。
这件事让我更确定一条工作习惯:发现反直觉的数字时,先怀疑测量口径,而不是先怀疑方案本身。 两次修正都不是方案错了,是我在报数字的时候图快,没把口径对齐。
对症的做法是把 345 行 prompt 量化拆解:其中“决策策略”占 32.9%、“非空输出策略”11.5%、“时间理解规则”8.7%——这些讲的是“要不要查、查什么”,第一轮用完即可丢弃。所以新增了一个精简版 followup prompt(2,122 字符,比原来降 70%),只保留字段规则、输出契约、两条防幻觉自检约束。第一轮用完整 345 行版本,工具执行完之后,第二轮的 system 消息整段替换成精简版。
这里有个实现上的硬约束:只能替换 system 消息,assistant(tool_calls) 和它对应的 tool 结果必须成对留在原位,动了这对配对 provider 会直接报错找不到 tool_call_id。效果是第二轮消息体积从 15,203 字符降到 4,192,降了 72%。
第三次意外:想用的优化技术,此路不通
顺手探测了一下 prompt caching 能不能用——如果固定的那部分 prompt 能被供应商缓存住,重复计费的问题就能再省一层。当时项目已经切换到 Claude 中转站,所以这次探测的是“这个中转站是否支持”,而不是原本设想的 DashScope。
三组证据摆在一起,结论很干脆:不支持。
- 官方返回的
usage.prompt_tokens_details.cached_tokens字段本身就不可信,固定 prompt 连续 6 次调用命中模式是0, 0, 命中, 命中, 0, 命中——真缓存命中后应该在 TTL 内稳定,不会中途跳回 0 - 延迟没有下降趋势,5 次调用反而从 3.42s 涨到 5.40s
- 显式传官方要求的 Anthropic 原生缓存标记
cache_control: {"type": "ephemeral"},直接被静默忽略,usage 毫无变化
这不是“暂缓再看”,是确认此路不通。判断一个技术路径能不能走通,光看官方文档不够,得自己拿真实调用去戳好几次、换几个角度交叉验证,再下结论。当时的心态调整是:不纠结于“怎么让它支持”,直接接受现实,把已经做完的 prompt 拆分当成当前唯一有效的手段。
一个反直觉但被证明是对的判断:模型主动查了本不该查的东西
实验里还发生过一次“以为是缺陷,查完发现是我设错了预期”的事。原本设的预期是“纯今日排期场景不该调工具”,实测里有真实项目时模型却主动查了 query_project_tasks。查 prompt 后确认模型没错——prompt 里明确要求“基于项目锚点做受控发散”,模型是照着规则走的,是我预期设错了。
工具调用行为的准确性倒是在另一组场景里验证得很干净:纯今日排期场景 3/3 不调工具,需要历史背景 3/3 调 query_tasks_by_range(参数上模型自己算出了 plan_date 前 7 天的日期区间),需要项目拆解 3/3 调 query_project_tasks。还有一个更能说明问题的对比——“需要历史背景”场景下 v3 稳定产出 1 张卡片,而 v1 是 2-3 张。查下去发现 v1 那 2 张额外卡片基于实验脚本伪造的假数据(查库确认该区间真实任务数是 0),v3 查的是真实数据库拿到的空结果。v3 基于事实,v1 基于虚构素材。 这顺带暴露了全量装配的一个固有弱点:它的正确性完全依赖装配层的正确性,装配层塞了脏数据,模型自己是无从察觉的。
最坦诚的一次自我推翻:开关能切,但切了也没用
改造做完最后一步是加一个配置开关 aido.planner.engine=${PLANNER_ENGINE:langgraph},让 backend 可以在 langgraph 和 harness 两条链路之间切换,同时新增一个契约稳定的正式端点 /api/v1/plan/generate-sync-harness(原来的实验端点 /api/v3/plan/generate 保留,两者分开)。功能测试全过:agent 访问日志能看到新端点返回 200、响应里 metrics.engine 显示 harness、重启前测试确认新端点原来是 404 的,排除“本来就通”的可能。
但认真捋一遍真实的调用路径之后,发现一个更要紧的事实:前端调用逻辑是 SSE 优先,只有流式失败、!receivedFinalPlan 时才兜底调同步接口。而流式端点 buildGenerateStreamEndpoint 硬编码走的是 /api/v1/plan/generate,isHarnessEngine() 这个判断在那条路径里根本没被调用过;isHarnessEngine() 目前只出现在同步链路的两处代码里。
拼起来就是:这个开关打开之后,正常用户走的还是老链路。harness 只在 SSE 出故障的异常路径上才会被真正执行到。
我原本可以把这件事悄悄带过去,报告里写“灰度开关已完成”——测试确实都通过了。但那样的表述会让人误以为这是一条可以随时切生产流量的路径,而事实不是。所以我把这一条单独记下来:开关本身可用,但它切换的那条路径在生产中几乎不被走到,要真正切过去,得先给 harness 补流式实现(AgentLoop 目前是 await 到底的收敛模型,改成流式产出工作量不小),或者让前端放弃流式优先、直接暴露 35 秒左右的同步延迟——这是产品决策,不是一个可以靠代码悄悄修好的技术债。
一个更隐蔽的坑:静默降级没有任何信号
改造里让我最不放心的一条设计是:精简装配模式(slimMode)下,backend 直接跳过历史任务查询、项目任务明细查询,前提是模型会用工具主动查回来。这个前提在代码层面没有任何保障。
真实测试里就撞上了一次反例:同样跑一遍端到端验证,sonnet-5 和 haiku-4.5 两个模型表现完全不同——sonnet-5 那一轮 llm_iterations: 1,意味着模型一个工具都没调(按 AgentLoop 的设计,只要请求了工具,轮次必然 +2),而它拿到的输入比对照组更少,耗时却是 35.6 秒,比对照组更长。系统对这种情况没有任何察觉,请求正常返回 200,只是结果悄悄变差了。
反倒是 haiku-4.5 那一轮撞上了此前只有单测覆盖过的两条容错路径:llm_iterations: 3,日志里出现“output parse failed, asking model to retry”和“model still requested tools after forced convergence, ignoring”,最终仍然产出了合规的 5 个 item,但收敛方式是 forced(强制收敛)而不是 normal。haiku 更快但输出稳定性弱一档,这类强 schema 约束场景需要留意。
这种“模型没查,但也没报错”的情况是最难排查的一类问题,因为它不会让请求失败,只会让结果悄悄变差。补的办法是加一条专门的降级告警:harness 模式下如果工具调用次数是零,就打一条 WARN 日志,明确写清楚是精简 context 没有被按需查询补偿,可能是静默降级。这条日志现在还没在生产上真正验证过效果,但至少让这个问题从“不可见”变成了“可搜索”。
同一轮排查还顺带发现了一个更基础的问题:agent 侧的 logger.info 之前全部丢失,因为 root logger 没配 handler,只有 WARNING 及以上级别靠 Python 的 lastResort 机制才能露出来。端到端验证时想看 harness 每一步的执行记录,日志里完全找不到,只能从 backend 记录的 agent 原始响应里反推。这个问题不是这次改造引入的,但它让整套可观测性设计在生产上形同虚设,排查起来会很被动,所以顺手补了一个模块级的日志配置,并且专门做了幂等保护——uvicorn --reload 会重新导入模块,不做保护的话每条日志会叠着打印好几遍。
这次改造真正学到的东西
如果只留一条经验,我会留这条:架构决策的依据应该是必要性,不是某一轮的性能数字。 全量装配这个模式,随着用户活跃度越高,浪费的比例只会越大,这个判断不会因为某次实验延迟测出来更好或更差而改变。
但这不代表可以对数字视而不见。反直觉的实测结果(token 涨了、缓存不支持、开关切了也没用)每一次都值得认真拆开去看清原因,而不是为了保住原本的结论去回避它们。三次预期落空,三次都往前走了一步——不是因为我猜对了,是因为每次数据打脸之后,我都重新去查了到底哪里错了。
harness 现在的定位更准确的说法是:一条经过测试、随时可用、但还没被真实生产流量检验过的备用实现。它没有失败,只是还没到能光明正大上生产的那一步。
相关内容
模型负责想,harness 负责做。这篇整理业界对 agent harness 的定义和五个核心职责,再用我自己在 Aido Planner 上做的一次真实改造,把抽象概念钉在一个具体案例上。
这次 Aido Planner 优化,我真正想解决的不是再把 today 任务重排得更花,而是让智能体开始从未完成任务、偏好、自然语言、长期计划和模板里判断“今天最该做什么”。
把零散的提示词整理成结构化的 Skill 体系,中间踩过的坑和最后收敛出的分层模型,是一次很典型的从“能用”到“可维护”的工程化过程。