ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Agent Harness 的 call_llm 只认本地 Ollama?TaoToken 这样改 .env 里的模型通道

Agent Harness 的 call_llm 只认本地 Ollama?TaoToken 这样改 .env 里的模型通道 call_llm 只认本地 Ollama 这件事TaoToken 用一个环境变量就能拆掉先去 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end 注册、创建 Key再把 .env 里的模型通道从本机端口换成统一的 API 通道Base URL 填 https://taotoken.net/api。改动面小到有点反直觉——Agent Harness 的任务判断、检索、提示词拼装、fallback 逻辑一行都不用碰你只是把「模型从哪来」这一个变量换掉。但恰恰是这一步决定了这套教学 Agent 能不能脱离某台开着 Ollama 的机器能不能在 CI 里跑出真实模型的返回而不是永远靠 stub 假装成功。这篇按附录四的最小闭环来讲classify_task → retrieve_course_context → build_prompt → call_llm → fallback → finalize。六步里五步都是本地纯逻辑只有 call_llm 是外呼所以改造的着力点也只在这里。下面会先把单点依赖讲清楚再落到 .env、provider 分支、模型 ID 这三处具体改法最后用 /api/documents 和 /api/agent/run 两个接口验收并给出 401、404、误落 fallback 的对照排查。1. call_llm 为什么只认本地 Ollama六步闭环里的单点依赖1.1 classify_task 到 finalize只有 call_llm 出了本机把这条链路摊开看classify_task 是规则或小模型做意图判断retrieve_course_context 是从向量库或本地文档切片里捞上下文build_prompt 是把任务、上下文、输出格式约束拼成一段提示词finalize 是把模型输出裁成结构化结果并附上 citations。这四步跑在进程里耗时稳定、可断言测试也好写。真正的不确定性全压在 call_llm 上它要出网、要鉴权、要选模型、要处理超时。而 .env 里的 LLM_PROVIDER 一旦写成 ollama就等于把这一步焊死在 127.0.0.1 的某个端口上。同事 clone 下来跑不起来CI 跑不起来你换了台笔记本也跑不起来——因为模型不在代码仓库里而在你上一台机器的显存里。这种依赖在演示时最要命。检索结果和提示词都对steps 打到 call_llm 就停住然后一路掉进 fallbackanswer 变成模板化的兜底话术citations 还挂着看起来「跑通了」其实一句模型输出都没有。诊断这类问题的第一步不是去看检索而是去看 call_llm 这一步的 observation 里到底写了什么。1.2 conftest.py 里那句 LLM_PROVIDERstub 救急也救穷很多工程在测试阶段会加一段 autouse 的 fixture把所有用例的 LLM_PROVIDER 强制打成 stub。好处很直接不依赖网络、不依赖本机模型、跑得飞快、断言稳定。坏处也很直接你验证的从来不是真实模型的返回而是 stub 那段写死的字符串。于是出现一种很尴尬的状态——本地开发时局域网里的 Ollama 一停接口演示和自动化测试同时变慢甚至失败测试环境为了绿把 provider 钉死在 stub等到真正要给人看「模型能答出 login-lab.md 里的登录重试逻辑」时才发现真实通道从来没被验证过。提示stub 不是坏设计它是兜底不是主路。正确的姿势是保留 stub 作为 CI 的默认同时留一条显式的真实通道用例用真实 Key 跑跑完把 steps 里的 call_llm observation 打出来。要把这条真实通道接上最省事的做法不是自己搭服务、也不是让每个人本地都装一套模型而是换一个统一入口Key 和 Base URL 从一个地方拿Harness 其余部分原地不动。这也是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end 在这套教学 Agent 里的全部角色——它不发模型幻觉它只负责让 call_llm 有个稳定可用的出口。2. 改 .env 不改 Harness把模型 provider 指向统一 API 通道2.1 .env 里真正要动的三行先明确边界这次改造不允许动 classify_task 的判定条件、不允许动 retrieve_course_context 的 top_k、不允许动 fallback 的触发规则。能在 .env 里表达的就不要写进代码。这样出问题时你可以一键回滚到 ollama 或 stub而不用 git revert 一堆文件。需要新增或修改的本质上就三行Base URL、Key、模型 ID。Key 从 TaoToken 控制台创建创建完立刻复制别指望它后面还能在页面上原样看回来。# .env # 可选值stub | ollama | openai_compatible LLM_PROVIDERopenai_compatible # 统一 API 通道的基址末尾不要加 /v1 LLM_BASE_URLhttps://taotoken.net/api # 从 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_end 创建后填入 LLM_API_KEYYOUR_API_KEY # 模型 ID 以模型广场当时列表为准直接复制别手写 LLM_MODELYOUR_MODEL_ID有三处细节最容易翻车。第一Base URL 写成 https://taotoken.net/api 就够了末尾不要补 /v1多一层路径是 404 的高频来源。第二别把落地页地址填进 LLM_BASE_URL落地页是给人点开注册、建 Key、看模型广场用的接口地址和它不是一个东西。第三模型 ID 不要凭印象编带日期后缀的写法尤其容易猜错去模型广场复制现成的。2.2 provider 分支openai_compatible 怎么落进代码provider 层通常长这样读环境变量按值分发到不同客户端。要加的不是一个新协议而是一个走 OpenAI 兼容协议的通用分支。改造后stub 分支照旧抛错交给上层 fallbackopenai_compatible 分支读 Base URL 和 Keyollama 分支保留作为本地离线备选。# app/llm/client.py import os from openai import OpenAI def build_client() - OpenAI: provider os.getenv(LLM_PROVIDER, stub) if provider stub: raise RuntimeError(stub 模式不产生真实外呼交给上层 fallback) if provider openai_compatible: return OpenAI( base_urlos.environ[LLM_BASE_URL], # https://taotoken.net/api api_keyos.environ[LLM_API_KEY], # YOUR_API_KEY ) if provider ollama: return OpenAI( base_urlhttp://127.0.0.1:11434/v1, api_keyollama, ) raise ValueError(f未知的 LLM_PROVIDER: {provider})调用侧只关心「拿到文本、拿到用量、把结果写进 observation」。call_llm 的返回结构不要因为换了 provider 就变否则 finalize 和 citations 的拼装逻辑会被连带改坏。# app/agent/steps.py import os from app.llm.client import build_client def call_llm(prompt: str, model: str | None None) - dict: client build_client() resp client.chat.completions.create( modelmodel or os.environ[LLM_MODEL], messages[{role: user, content: prompt}], temperature0.2, ) return { provider: os.environ.get(LLM_PROVIDER), model: resp.model, text: resp.choices[0].message.content, usage: resp.usage.model_dump() if resp.usage else None, }2.3 模型 ID 从模型广场复制别自己拼日期后缀模型 ID 是这套配置里最容易「看起来对、跑起来错」的一项。很多网关的模型名和上游并不一致你按习惯写一个带日期后缀的名字请求会直接返回模型不存在的错误而错误信息往往看起来像鉴权问题排查方向一下就偏了。最稳的做法打开 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_content 的模型广场找到你要用的那一个直接用页面上的 ID 覆盖 .env 里的 LLM_MODEL。想做 A/B 对比时也不要改代码直接把 LLM_MODEL 换成另一个 ID重启服务即可。这样 prompt 侧没有任何差异只有模型在变步骤对比才干净。注意具体有哪些模型、叫什么名字以模型广场当时列表为准。不要在配置里硬编码一个猜出来的名字更不要写成正式默认值提交到仓库。3. 照着原流程验证/api/documents 建索引、/api/agent/run 看 call_llm3.1 先 POST /api/documents等 status 变成 indexed配置改完不要急着发聊天请求。这套 Harness 的验收顺序和原文一致先建知识再跑任务。把 login-lab.md 传进去让检索层有东西可捞否则 retrieve_course_context 会返回空上下文build_prompt 里没有素材模型答得再对也没法给出 citations。curl -s -X POST http://127.0.0.1:8000/api/documents \ -F filedocs/login-lab.md \ | jq {doc_id, status, chunks}第一次返回的 status 多半是 processing 或 pending切片和向量化需要一点时间。等它变成 indexed 再往下走否则你会误以为是模型通道的问题。用一个轻量的轮询确认即可curl -s http://127.0.0.1:8000/api/documents/{doc_id} \ | jq {status, chunks, updated_at}状态是 indexed、chunks 数量不为 0说明检索层已经就绪。这一步和模型 provider 无关即使 Ollama 停着也应该成功——如果这里就失败了先别动 .env去查文件和解析逻辑。3.2 再 POST /api/agent/run盯 steps 里 call_llm 的 observation接下来跑一个真实 task。任务内容围绕 login-lab.md 提问让链路必须走到 retrieve_course_context否则你验证的只是纯问答验证不了整个闭环。curl -s -X POST http://127.0.0.1:8000/api/agent/run \ -H Content-Type: application/json \ -d {task: login-lab.md 里登录失败重试和账号锁定是怎么设计的, top_k: 4} \ | jq {answer, citations, steps: [.steps[] | {name, status, observation}]}重点看三件事。第一steps 数组里应该有完整的六步顺序是 classify_task、retrieve_course_context、build_prompt、call_llm、fallback、finalize。第二call_llm 这条的 observation 里应该出现模型返回的文本片段或字符数而不是空的、也不是「providerstub」。第三fallback 那一步应该是 skipped 或者 noop而不是 triggered。只要 call_llm 的 observation 有真实内容、fallback 没被触发就说明 .env 这次改动生效了Harness 的任务判断和工具调用逻辑一行没动外呼出口已经从本机换成了统一 API 通道。如果想确认这一步确实打到了远端可以在 call_llm 的 observation 里带上 provider 和 model 两个字段——这也是前面返回结构里保留它们的原因。3.3 answer 与 citations 齐不齐是这次改造的验收标准有些实现只要 call_llm 有返回就算过这是不够的。真正的验收标准有两个answer 是否覆盖了 login-lab.md 里的关键点citations 是否指回了正确的文档切片。因为 call_llm 的输出最终要经 finalize 裁剪如果模型返回的格式和 prompt 里的约束不一致finalize 可能把内容裁掉一半表现为 answer 很短、citations 空。跑完之后把 steps 里的 call_llm observation 和最终 answer 对照看一眼observation 里明明有解释重试次数的段落answer 里却没有那问题在 finalize 的解析规则不在模型通道。反过来observation 是空的、answer 是兜底话术那才是通道没通回去查 .env 和 Key。到此classify_task 到 finalize 这条链路就完整跑通了而且不依赖任何一台机器上的本地模型。想再核对一遍 Key、额度与调用记录可以直接登录 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_content 看控制台。4. call_llm 报错与 fallback 的对照排查4.1 401、404、路径重复的三种长相换了通道之后最常见的三类错误都发生在 call_llm 这一步但表现各不相同分清能省不少时间。现象大概率原因处理方式401 / invalid api keyLLM_API_KEY 没填、填了占位符、或 Key 被删去控制台重新创建覆盖 .env 里的 YOUR_API_KEY404 / model not foundLLM_MODEL 是手写的名字从模型广场复制准确 ID重启服务404 / path not foundBase URL 末尾多写了 /v1 或漏了前缀保持 https://taotoken.net/api不追加路径还有一种很隐蔽的Key 是对的、模型 ID 也是对的但环境变量没有被加载。很多人改完 .env 直接重启了 uvicorn却忘了有些启动方式会把变量在进程启动时固化用 python -m 或者进程管理器拉起时不会重新读文件。改完配置先确认一遍进程环境里读到的值再怀疑远端。4.2 什么样的调用走 fallback 不算 bugfallback 是设计的一部分不是失败的同义词。prompt 超长、模型返回内容无法按预期解析、网络抖动导致单次超时——这些都该走 fallback让最终 answer 至少有个可用结果而不是让整个请求 500。判断标准放在 steps 里如果 call_llm 的 observation 有内容、只是最终 shape 没对上fallback 触发属于正常兜底如果 observation 本身就是错误信息比如 401、connection refused、timeout那 fallback 只是把问题掩盖了必须先把通道修好。教学场景尤其要注意不要把「走了 fallback 但接口返回 200」当成跑通。注意改 provider 的时候千万别顺手改 fallback 的触发条件。这两件事耦合在一起你以后就分不清是通道问题还是兜底逻辑问题了。4.3 慢、超时、并发别把锅扣给 Harness本地 Ollama 慢通常是显存不够或者模型太大换成统一通道之后慢的原因换了可能是所选模型本身的推理速度也可能是并发把连接池打满了。call_llm 这一步是同步外呼一个请求压着不动后面排队的 steps 日志就一直停在 build_prompt 完成、call_llm 进行中。处理顺序建议是先给 call_llm 单独加超时和重试上限超时值写进 .env 方便调再看看是不是同一个测试文件里并发跑了几十个用例把并发压到两三最后才考虑换模型。不要在还没确认通道连通的情况下就去调 temperature 或者改 prompt那是把两个变量一起动了。5. 让这套教学 Agent 不再绑定某台机器的 Ollama5.1 conftest.py 保留 stub 兜底另加一条真实通道用例改造完成之后测试策略应该是两层而不是二选一。默认层继续用 stub快、稳、不花额度用来保护 classify_task、retrieve_course_context、finalize 这些纯逻辑。真实层单独标一个 marker只跑少量端到端用例验证 call_llm 真的能拿到外部返回。# tests/conftest.py import os import pytest pytest.fixture(autouseTrue) def stub_llm(monkeypatch): 默认把所有用例钉在 stub保证 CI 稳定。 monkeypatch.setenv(LLM_PROVIDER, stub) monkeypatch.setenv(LLM_API_KEY, stub-key)# tests/test_real_channel.py import os import pytest pytest.mark.real_llm def test_call_llm_uses_external_channel(monkeypatch): monkeypatch.setenv(LLM_PROVIDER, openai_compatible) monkeypatch.setenv(LLM_BASE_URL, https://taotoken.net/api) monkeypatch.setenv(LLM_API_KEY, os.environ[CI_LLM_API_KEY]) monkeypatch.setenv(LLM_MODEL, os.environ[CI_LLM_MODEL]) from app.agent.steps import call_llm result call_llm(用一句话说明登录失败重试的目的是什么。) assert result[text].strip() assert result[provider] openai_compatibleCI 里用 -m not real_llm 跑主流程用 -m real_llm 单独跑通道检查。这样本地谁没开 Ollama、谁没配模型都不影响别人提交代码而真实通道有没有退化也有一条明确的用例盯着不会再出现「stub 全绿、演示全挂」的场面。5.2 收尾去控制台确认这次 call_llm 记上账最后一件事是回到控制台核对。跑完那几轮 /api/agent/run 之后用同一把 Key 看看这次有没有被正确记录模型 ID 是不是你以为的那个用量是不是落在预期范围内。这一步既是验收也是以后排查「为什么昨天还好好的」的基线。想先用最轻的方式确认通道可以在 TaoToken 模型对话 里发一条测试消息把 .env 里那三个值原样填进去确认无误后再看 Coding Plan 是否够你日常跑这套 Harness 和自动化用例需要换 Key 或给 CI 单独发一把时直接在 控制台 API Keys 里建。Key 的创建入口和模型广场都在 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_content 上先把这次链路的 provider 固定下来再谈别的优化。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进