[{"content":"nanobot #4867 最初看起来已经解决了。\nThe-Markitecht 报告说，同一个 llama3.1:8b，直接通过 Ollama 调用很快；经过 nanobot 后，一轮对话却可能多花几十秒。日志中最明显的差别来自缓存：直接调用时，第二个请求能复用大部分 prompt；nanobot 的第二轮主请求只能复用四成左右。\n我最初把原因定位到运行时上下文。时间、channel、sender 等临时信息会在下一轮重新生成，导致旧消息的前缀发生变化。#4891 修复了这部分逻辑；在我的复现中，缓存复用率从 14.2% 提高到 95.5%。到这里，问题似乎已经收束。\n一周后，The-Markitecht 拉取包含修复的 main 再测，耗时和之前基本一样，第二轮主请求的命中率仍然只有约 42%。\n这条反馈让我重新审视先前的结论。#4891 的对照实验足以说明它消除了一类前缀变化，却没有解释用户实际遇到的全部性能损耗。修复后仍然只有约 42% 的命中率，究竟是因为修复没有覆盖真实路径，还是同一份日志里还叠着另一个问题？\n在另一台机器上复现约 42% 的命中率 测试环境如下：\nnanobot main，commit e3de01c9 Ollama 0.32.1 llama3.1:8b context window 16384 OLLAMA_NUM_PARALLEL=1 RTX 5090 32 GB 我先绕过 nanobot，直接向 Ollama 发送两个带有相同长前缀的请求。第二次 prompt evaluation 从 563.9 ms 降到 11.9 ms，说明 Ollama 的 KV cache 本身工作正常。\n接着通过 nanobot CLI，在同一个 session 里依次发送：\nanswer 2+2 answer 4+7 两轮中，llama3.1:8b 都选择先调用 exec，再根据工具结果生成最终答案。Ollama 日志中的请求顺序如下：\n请求 Prompt tokens 初始缓存 命中率 第一轮主请求 8436 2 0.02% 第一轮工具后请求 3734 3689 98.80% 第二轮主请求 8495 3743 44.06% 第二轮工具后请求 3793 3748 98.81% 44.06% 与 The-Markitecht 复测时看到的 42% 很接近。第二轮主请求需要重新计算 4752 tokens；RTX 5090 只用了约 390 ms，所以绝对延迟并不显眼，但换到 prompt evaluation 较慢的本地硬件上，这部分计算就可能拖到几十秒。\n请求一长一短地交替，很容易让人想到 cache slot：工具调用后的短 prompt 占据当前 slot，下一轮的长请求因此只能从它开始复用。但这只能解释缓存为什么停在 3.7k tokens，不能解释短 prompt 本身从何而来。仅凭 task.n_tokens，也无法判断它究竟在哪一步变短。\nJSON 变长，prompt 却变短 为了确认 nanobot 实际发出了什么，我打开 Ollama 的请求日志：\nOLLAMA_DEBUG_LOG_REQUESTS=1 两次原始 HTTP 请求的结构分别是：\n第一次：2 条 messages，19 个 tools，40109 个 JSON 字符 第二次：4 条 messages，19 个 tools，40363 个 JSON 字符 第二次多出的两条消息正是 assistant tool_call 和 tool result，两次工具定义序列化后的 SHA-256 也完全相同。OpenAI-compatible API 这一层没有丢历史，也没有替换工具定义；nanobot 发出的 messages 确实只增不减。\n然而，Ollama 渲染给模型的 prompt 长度却是：\n第一次：38415 字符 第二次：16064 字符 原始 JSON 增长了，最终 prompt 反而缩短一半以上。变化发生在 API 请求之后、模型推理之前，范围由此收窄到模型的 chat template。\n工具定义在 chat template 中移动了 Ollama 当时为 llama3.1:8b 提供的模板里有这样一个判断：\n{{- if and $.Tools $last }} ... render tool definitions ... {{- end }} 这段逻辑位于 user message 分支。只有当前 user message 同时也是整个 messages 列表的最后一条时，模板才会展开完整的工具定义。\n因此，同一段对话会被渲染成三种不同的 token 布局。\n第一轮主请求的最后一条消息是 user：\nsystem 工具定义 问题 2+2 模型调用工具后，最后一条消息变成 tool，先前的 user 不再是 $last，工具定义随之消失：\nsystem 问题 2+2 assistant tool_call tool result 下一轮用户消息到来，工具定义重新出现，但位置已经移到第二个问题之前：\nsystem 上一轮消息与工具结果 上一轮最终回答 工具定义 问题 4+7 nanobot 的 JSON 始终保持 append-only，模型实际看到的 token 序列却没有简单追加：工具定义先出现，再消失，随后又在新位置出现。\nKV cache 只能复用从开头连续一致的 token，无法从旧请求中拼接几个不连续的片段。工具定义的位置一旦发生分叉，后续状态就必须重新计算。这正好解释了第二轮主请求为什么只能命中约 3.7k tokens。\n把工具定义固定在 system 中 为了验证模板是否正是主要损耗来源，我没有修改 nanobot，而是基于 llama3.1:8b 创建了一个新的 Ollama tag：只把 .Tools 移到固定的 system block 中，user 分支仍然只渲染用户内容。\n原模板：tools 只在 user 为最后一条消息时展开 修改后：tools 始终在 system 中展开 新 tag 与原模型共享同一份 4.9 GB 权重，实际只增加了 2757 字节的模板和 manifest。随后，我用同一版本的 nanobot、相同的 context 和单 slot 配置重新跑了两轮。\n结果如下。表格中的数字是“初始缓存 tokens / Prompt tokens”：\n请求 官方模板 固定工具模板 第一轮工具后请求 3713 / 3758 8468 / 8499 第二轮主请求 3767 / 8519 8505 / 8520 第二轮工具后请求 3772 / 3817 8539 / 8562 第二轮主请求的初始命中率从 44.22% 升到 99.82%，需要重新计算的 tokens 从 4752 降到 15。两套模板都实际调用了两次 exec，也都正确回答了 4 和 11。\n这个 A/B 实验基本确认：在 llama3.1:8b、Ollama 0.32.1 和单 cache slot 这组配置中，剩余的大部分缓存损耗来自 chat template 对工具定义位置的处理。\n完整的诊断步骤和 Modelfile 整理在 nanobot #4998 中。这份模板目前只验证了简单的 exec 工具回合，还不能直接当作所有 Ollama 模型的通用配置。\n两处前缀变化，两次不同的修复 回到 #4867 ， 现在可以把缓存损耗拆成两个彼此独立的环节。\n第一处在 nanobot。时间等临时信息会注入当前 user message，却没有按模型实际看到的形式持久化；下一轮重建历史时，旧前缀随之改变。#4891 修复了这个持久化问题，命中率从 14.2% 升到 95.5%，反映的正是这项改动带来的改善。\n第二处在 llama3.1:8b 的 Ollama 模板。正常的 agent tool loop 会改变最后一条消息的角色，模板因此省略工具定义，或把它移到新的位置。issue 提交者更新后继续看到的约 42%，主要来自这里。\n两组结果并不矛盾。#4891 消除了一处前缀变化；但在这个模型上，模板造成的重新计算仍然很大，足以掩盖前一项修复的收益。硬件性能决定重新计算 4752 tokens 需要 390 ms 还是几十秒，却不会改变缓存命中率本身。\n日志能说明什么 这次排查中，最容易造成误判的是把 OpenAI-compatible API 里的 messages 等同于模型最终看到的 prompt。JSON 保持 append-only，只能证明 nanobot 没有删改消息；经过 chat template 后，token 序列仍可能完全变形。\n同样，task.n_tokens 从 8k 降到 3k，只能说明渲染后的 prompt 变短，无法直接证明调用方删除了历史。排查这类问题时，需要把 API 请求、模板渲染结果和推理引擎的缓存日志分开观察。\nOllama 日志本身还有一个容易踩的坑：每个 new prompt 之后，紧接着的第一条 cached n_tokens 才代表初始命中；后面逐步增长的同名日志只是 prompt evaluation 的进度。\n最终让判断落定的仍是对照实验：不改 nanobot，不换模型权重，只移动工具定义的位置，命中率就从 44.22% 升到 99.82%。这组结果把变量收窄到了模板。\n这篇记录只覆盖 Ollama 0.32.1、llama3.1:8b、单 slot 和简单工具调用。其他模型的 tool-call 格式、多个并行工具、工具错误和长会话，仍需要分别验证。\n","permalink":"/blog/notebook/%E4%B8%80%E6%AC%A1%20ollama%20%E5%B7%A5%E5%85%B7%E8%B0%83%E7%94%A8%E7%BC%93%E5%AD%98%E9%97%AE%E9%A2%98%E7%9A%84%E6%8E%92%E6%9F%A5/","summary":"\u003cp\u003e\u003ca href=\"https://github.com/HKUDS/nanobot/issues/4867\"\u003enanobot #4867\u003c/a\u003e 最初看起来已经解决了。\u003c/p\u003e\n\u003cp\u003eThe-Markitecht 报告说，同一个 \u003ccode\u003ellama3.1:8b\u003c/code\u003e，直接通过 Ollama 调用很快；经过 nanobot 后，一轮对话却可能多花几十秒。日志中最明显的差别来自缓存：直接调用时，第二个请求能复用大部分 prompt；nanobot 的第二轮主请求只能复用四成左右。\u003c/p\u003e","title":"一次 Ollama 工具调用缓存问题的排查"},{"content":"Why we prompt? 2023 年 2 月 24 日是我第一次使用大模型。那时我刚开始学 C++，GPT-3.5 给了我不小的震撼。“What is xxx?” 大概是那一年我最常用的开场白。\n现在，我们早已不满足于问“What is xxx?”。nanobot、openclaw、Claude Code、Codex，各种 agent 像雨后的蘑菇一样长出来。一个人坐在电脑前，说一句“Hi agent, help me do something”，屏幕那边就开始写代码、改页面、查日志、补测试。偶尔犯蠢，但也蠢得很勤快。\n这件事有一种很新鲜的荒诞感。过去资本家压榨人，还要给人配电脑、工位和咖啡。现在好了，一个人能指挥好几个 agent，像小包工头一样在屏幕前发号施令。老板也学会了新的语言：「我一下午做了个 demo，80% 的情况下可用，你把它完善一下，明天上线。」听起来像进步，仔细想想，也像把绳子换成了无线的。\n以前写普通的业务代码，接口怎么接，字段怎么传，页面怎么拼，手会顺着一条线往下敲。现在我可以同时开几个 agent：这个改前端，那个补测试，另一个查线上问题。它们都在干活，我也没有闲着。我在拆任务、补上下文、读 diff、纠偏、验收，再把一个 agent 的输出喂给另一个 agent。写代码的人变成了调度员，脑子被迫开了多线程。\n这和我想象中的解放不太一样。\n我原本期待 agent 把我从低价值的重复劳动里拉出来，让我去想更有意思的事。它确实接走了不少执行工作，却也带来了另一种焦虑：最新模型、最新框架、最新工作流，好像每天都有一班车要赶。真正的问题还不是 agent 偶尔犯蠢，而是我们太容易把没想清楚的东西交给它，再让自动化批量放大。\n以前我们问“What is xxx?”，现在开始谈 loop engineering，谈从写 prompt 转向写 loop，用 loop 自动调度 agent，让一切自己运转。这个想法有它合理的地方。CI 不需要人盯着跑，格式化不需要人手敲，固定模式的迁移和检查也不该靠肉眼一遍遍看。能交给机器的苦活，确实应该交出去。\n问题在于，loop 这个词越来越像万能胶水：仿佛把几个 agent 串起来，人就可以从项目里消失。可写代码有太多执行之外的事。什么该做，什么该放弃，边界在哪里，风险能不能接受，用户真正需要什么，今天这坨东西有没有资格交付——这些判断没有那么自动。\nagent 可以很聪明，可以生成代码、解释错误、补测试、开 PR。可它并不知道这个产品为什么存在，也不会在事故复盘会上脸红。方向和责任外包不出去，最后坐在那里看结果的人还是你。\n所以我越来越觉得，prompt 并不落后。写 prompt，其实是在做一件很古老的事：把脑子里那团含混的东西说清楚。目标是什么，背景是什么，哪里不能碰，什么结果可以接受，什么时候应该停下来问人。一个需求从脑子里走到机器手里，中间有一段泥地。prompt 是人蹚过去时留下的脚印。\nprompt 和 loop 也不是非此即彼。loop 负责让工作持续运转，prompt 则是人的判断进入这套循环的重要接口。机器可以执行，也可以推理，但它不会天然知道什么值得做。少了人的校准，自动化会变成一条顺滑的传送带，把错误、幻觉和没想清楚的需求一路送到用户面前。\n构建产品的门槛已经很低了，低到我们很容易误以为“做出来”就是“做好了”。如果 agent 最后只让我们生产更多代码、接更多需求、开更多并行任务，那就太可惜了。它本该把人从重复执行里释放出来，让人有时间想清楚什么才值得做。\n如果用了 agent，我只是从一个写代码的人变成同时监工十个 agent 的人，那不过是工作方式变了，疲惫并没有减少。我不反对 loop，也不怀念手写代码。我关心 prompt，是因为我希望在越来越自动化的工具链里，人仍然留在真正重要的位置上：提出问题，判断方向，并为结果负责。\n","permalink":"/blog/notebook/why%20we%20prompt/","summary":"\u003ch1 id=\"why-we-prompt\"\u003eWhy we prompt?\u003c/h1\u003e\n\u003cp\u003e2023 年 2 月 24 日是我第一次使用大模型。那时我刚开始学 C++，GPT-3.5 给了我不小的震撼。“What is xxx?” 大概是那一年我最常用的开场白。\u003c/p\u003e","title":"Why we prompt?"},{"content":"最近沉迷 agent，所以对相关论文和 bench 比较感兴趣。下面这篇是我最近看到的比较炸裂的一篇。\nhttps://arxiv.org/html/2606.12344v1 https://github.com/opensquilla/claw-swe-bench\n我的结论很简单：这篇可以看看 bench 设计思路，但是实验结论基本没什么意义。原因也很直接：论文把单次运行、混杂对比和不完整实验网格写成了看起来很确定的结论。作者明知每个组合只跑一次，还在正文里讨论排名、机制和框架优劣，多多少少有点让我脑内自动浮现派大星翻白眼流哈喇子表情包。\n这篇论文讨论的是 SWE-bench 类榜单的一个老问题：表格通常只报解决率，但一个系统的分数同时受模型、提示词、工具接口、运行预算、补丁生成方式、停止条件和评测脚本影响。分数变了，读者很难判断变化到底来自模型，还是来自把模型组织成智能体的那套评测框架。\nClaw-SWE-Bench 想做的事，就是把评测框架单独作为变量。论文把这类框架叫作 claw，比如 OpenClaw、hermes-agent、zeroclaw、nanobot 和 GenericAgent。这个思路本身不新鲜，类似 HarnessBench 的工作已经在讨论 harness 对结果的影响。这篇的实际贡献，是做了一套统一运行器，把不同 claw 接到同一个 SWE-bench 风格流程里。\n这套流程大概是这样：每个任务都有 GitHub issue、仓库和 base commit，agent 在 Docker 里的 /testbed 改代码；退出后，运行器用 git diff 从仓库状态生成 model_patch，再交给官方 SWE-bench evaluator 判断是否 resolved。这样做的好处是，不同 agent 的最终回复格式不再重要，能统一进入同一个补丁评测流程。\n任务集叫 full-350，一共 350 个真实 issue-resolution 任务，覆盖 8 种语言和 43 个仓库。论文还做了一个 80 题的 Lite-80 子集，用 17 个校准实验列做选择，目标是让 Lite-80 的分数、排名和成本结构尽量贴近 full-350。论文也处理了一个确实重要的问题：部分 Multilingual 镜像能看到 base commit 之后的 Git 历史，可能泄露 gold patch，所以主实验会清理 future commit。\n这些工程设计是有价值的。统一运行器、runner-side patch collection、future commit 清理、成本记录，都是值得看、也值得复用的东西。问题出在实验结论。\n第一个大问题是 bare/full 对比。论文只看 OpenClaw + GLM 5.1，bare 是 67/350，Pass@1 19.1%，Apply Failed 69.1%；full 是 257/350，Pass@1 73.4%。这个差距很大，但它主要说明一件事：让模型在最终回复里手写 unified diff 非常脆弱；让模型改文件，再由运行器导出 patch，会稳定很多。\n它不能证明某个具体设计点有效。bare 和 full 同时改变了工作区对齐、提示词设计、future commit 清理、Git 补丁导出、补丁清理和最终输出约定。论文自己也说这个诊断实验不应当解释成 full 的单组件消融。54.3 个百分点的差距越大，越应该警惕：对比里混进去的变量太多，因果解释很容易被放大。\n第二个问题是模型和框架实验都太薄。固定 OpenClaw 换 9 个模型，论文说模型选择带来 29.4 个百分点的 Pass@1 spread。固定两个模型换 5 个 claw，GLM 组从 60.9% 到 73.4%，Qwen 组从 38.6% 到 66.0%。这些数字能说明评测框架会影响 SWE-bench 分数，但再往前说某个 claw 在一般意义上更好，证据就不够了。\n完整实验本来应该更接近 5 个框架 × 9 个模型。论文实际没有跑完整网格，非 OpenClaw 的四个框架只在 GLM 5.1 和 Qwen 3.6-flash 上测试。这个设计拆不清框架和模型之间的交互效应。论文 Limitations 里也承认这一点。\n因为我是 nanobot team 的成员，这里还是要单独看看 nanobot。表 3 里 nanobot 分数低，成本和缓存也异常：GLM 5.1 下 nanobot 成本 768.8 美元，高于同组其他框架；Qwen 3.6-flash 下缓存命中率 63.9%，明显低于 OpenClaw、hermes-agent、zeroclaw 接近 97% 的水平。我真不觉得这么明显异常的数据有多少参考价值。如果你跑一百遍，来个置信区间，我摸摸后脑勺自己就去改代码看看怎么改善；但是只跑了一次，还直接把它解读成 nanobot 本身能力差，我就觉得这个实验做得有点粗糙，并且伤害了我的感情。\nLite-80 的证据也要降级理解。它的构造很细：17 个校准列、语言平衡、难度分位、成本和排名都纳入选择目标。可是校准结果主要说明 Lite-80 贴合这 17 个实验列，外推能力还要看留出验证。论文的留出检查只有 OpenSQuILLA 一个系统，而且只比较 OpenSQuILLA 在 Lite-80 和 full-350 上的 Pass@1。这个检查可以说明 Lite-80 对一个外部系统没有明显跑偏；它不能证明 Lite-80 对新的框架/模型组合都稳，也不能保证排行榜关系稳定。\n最硬的问题还是统计证据。论文明确说主实验是 single-run aggregates，每个（任务、框架、模型）组合只跑一次。350 个 0/1 结果可以报描述性的 Pass@1；但当 n = 350、p 在 0.6 到 0.75 附近时，最朴素的二项标准误大约也有 2.3 到 2.6 个百分点，95% 区间量级接近正负 5 个百分点。更别说 agent 运行还有非确定性、远程 API 抖动、并发调度、缓存命中差异。\n在这个前提下，论文如果只说“我们观察到差异”，问题不大；一旦开始讨论稳定排名、设计机制或因果归因，就明显越过了实验设计能支撑的范围。GPT 5.5 78.0% vs Claude Opus 4.7 77.1%、GLM 5.1 73.4% vs DeepSeek-V4 Pro 71.7%、OpenClaw/GLM 5.1 73.4% vs hermes-agent/GLM 5.1 71.1% 这种相邻排名，不适合写成稳定座次。论文自己的 Limitations 也提醒 few percentage points 不应被过度解读，但正文和表格解读仍然让人看到一套像排行榜一样的叙事。\n成本指标也是类似情况。Total Cost 有意义，可以告诉读者这次实验实际花了多少钱；但 USD 受模型价格、输入/输出 token、缓存策略、框架调用路径、服务商价格表和计费档位影响。它适合作为运行记录，不适合当成稳定 benchmark 指标，除非同时发布足够细的 token、缓存轨迹和价格表版本。\n所以这篇论文最适合被理解成一个评测产物发布，很难当作一篇能支撑强结论的实验论文。它提供了 full-350、Lite-80、统一运行器、future commit 清理、运行器侧补丁收集和成本记录，这些东西有实际价值。它也确实说明，评测框架会影响 SWE-bench 类结果，成本应该和解决率一起报告。\n但 bare/full 的 54.3 个百分点不能作为干净的设计证明；单次运行还讨论机制归因和几个百分点的排名，是很低标准的实验设计；5 框架 × 2 模型的实验不能拆清框架和模型的交互；一个 OpenSQuILLA 留出检查不足以验证 Lite-80 的泛化性；美元成本受服务商和缓存计费影响很大。\n综上，带上个人感情给一句评价：作为工具发布，有一定价值；作为实验论文，标准太低。\n","permalink":"/blog/notebook/%E5%85%B3%E4%BA%8Eclaw%20bench/","summary":"\u003cp\u003e最近沉迷 agent，所以对相关论文和 bench 比较感兴趣。下面这篇是我最近看到的比较炸裂的一篇。\u003c/p\u003e\n\u003cp\u003e\u003ca href=\"https://arxiv.org/html/2606.12344v1\"\u003ehttps://arxiv.org/html/2606.12344v1\u003c/a\u003e\n\u003ca href=\"https://github.com/opensquilla/claw-swe-bench\"\u003ehttps://github.com/opensquilla/claw-swe-bench\u003c/a\u003e\u003c/p\u003e\n\u003cp\u003e我的结论很简单：这篇可以看看 bench 设计思路，但是实验结论基本没什么意义。原因也很直接：论文把单次运行、混杂对比和不完整实验网格写成了看起来很确定的结论。作者明知每个组合只跑一次，还在正文里讨论排名、机制和框架优劣，多多少少有点让我脑内自动浮现派大星翻白眼流哈喇子表情包。\u003c/p\u003e","title":"关于 Claw-SWE-Bench"},{"content":"之前 nanobot 的 cron 把结果发到聊天窗口后，这条消息并不会进入这个窗口的上下文。\n假设有个定时任务每天早上查天气，9:30 告诉我：“今天会下雨。”我顺手回一句：“太糟了。”agent 却问：“什么太糟了？”\n对用户来说这是同一段对话，对系统来说，cron 和聊天走的却是两套隔离的上下文。agent 接不上用户的回复，下一次 cron 触发时也看不到这个窗口里后来聊了什么。\n最开始我们把注意力放在了“这条消息要不要发”上。nanobot 曾经用一个 evaluator 对 cron 结果再做一次 LLM judge，避免后台监控每次都往 channel 里发送没有意义的结果。它解决的是通知噪音，却解决不了对话连续性：evaluator 可以决定发不发，不能让发出去的消息自然成为 session 的一部分。\n第一反应很容易是把 cron 结果直接塞回消息列表，或者作为 runtime context 注入当前对话。但继续往下想就会遇到更多状态：用户可能正在和 agent 聊天，也可能已经几天没有打开 nanobot。前一种情况下，新的 agent turn 会抢占正在进行的回复，像聊到一半突然被后台任务夺舍；后一种情况下，又根本没有一个正在运行的 context 可以注入。\n后来采用的方案更简单：创建计划任务时，把它绑定到当前 session；触发时，把它当作这个 session 里一次正常的 agent turn。这样 cron 的触发和结果都会进入同一套 history，用户可以直接追问，下一次任务也能看到这段对话。\n如果目标 session 正在运行，cron 不再注入当前 turn，而是先进入队列，等这个 session 空闲后再执行。这里真正需要的不是让模型理解一次特殊的后台插入，而是让同一个 session 的多个 turn 保持串行。\n所以 cron 和 session 的关系最后收束成了一条很明确的规则：cron 应该共享目标 session 的上下文，但不能抢占正在运行的 turn。这个方案未必覆盖了所有自动化场景，但已经比原来两套上下文各走各的自然很多。issue #1445 里的后续说明 也正是基于这个变化关闭的。\n“没有变化时不要通知”则是另一个问题。现在普通 cron 更适合提醒、定时汇报这类每次都应该产生结果的任务；需要周期性检查、只有出现有效变化才通知的监控任务交给 heartbeat，由 evaluator 决定是否值得打扰用户。把这两种自动化拆开后，不必再让一个 deliver 开关同时承担通知策略和上下文一致性。\n绑定 session 之后，生命周期也必须一起处理。Codex 的自动化任务可以绑定会话，因为会话只能归档，触发时还能重新拿出来。nanobot 的状态都在本地，用户可以真的删掉 session，也可以直接清理存储，不能假装这个绑定永远可以恢复。\n现在删除带有计划任务的 session 时，WebUI 会先列出关联的 automation 并阻止直接删除；用户确认后，再把这些任务一起删掉。WebUI 也增加了独立的 automation 管理页面，可以集中查看所有任务，再从任务回到它绑定的聊天。\n这也让我重新理解了“计划任务应该独立于聊天窗口”这件事。它的管理入口可以独立，执行上下文却不应该独立。前者解决任务怎么被找到和管理，后者决定一条自动消息能不能继续成为对话。\n","permalink":"/blog/notebook/cron%20%E5%92%8C%20session%20%E7%9A%84%E5%85%B3%E7%B3%BB/","summary":"\u003cp\u003e之前 nanobot 的 \u003ccode\u003ecron\u003c/code\u003e 把结果发到聊天窗口后，这条消息并不会进入这个窗口的上下文。\u003c/p\u003e\n\u003cp\u003e假设有个定时任务每天早上查天气，9:30 告诉我：“今天会下雨。”我顺手回一句：“太糟了。”agent 却问：“什么太糟了？”\u003c/p\u003e","title":"cron 和 session 的关系"},{"content":"恶意脚本检测有一个很实际的问题：真正有问题的代码可能只占一小段。脚本整体很长，但危险行为往往集中在某几行，例如解码、下载、执行或跳转。如果直接把长脚本截断成固定长度输入，就有可能把关键负载切掉，模型最后是在一段看起来正常的文本上学习“恶意”标签。\n我之前的方案是字符级 1D CNN。这个结构轻，适合端侧部署，但它的感受野有限。输入长度设得太短会丢上下文，设得太长又不代表模型真的能理解远距离依赖。实际测试里，即使用比较长的 input sequence length，长脚本截断仍然会引入明显噪声。\n一个更自然的处理方式是分块再聚合。把脚本看成一条长序列，按滑动窗口切成多个有重叠的 chunk，模型分别预测每个 chunk 的恶意概率，最后再把这些结果聚合成脚本级判断。比如模型得到 [0.1, 0.05, 0.98, 0.95, 0.2]，只要有一个 chunk 的概率超过阈值，就可以把整段脚本判为恶意。\n这个推理策略又会反过来影响训练。一个恶意脚本切成 10 个 chunk，可能只有 1 个 chunk 真的包含恶意代码，另外 9 个只是函数定义、注释或普通逻辑。如果把 10 个 chunk 全都当作正样本训练，模型会被迫把良性片段也拟合成恶意，误报风险会被放大。\n这里可以用 Multi-Instance Learning 来描述。脚本是一个 bag，chunk 是 instance。正 bag 只表示“至少有一个 instance 是正的”，不要求每个 instance 都是正的；负 bag 则表示里面的每个 instance 都应该是负的。这样训练目标就和脚本检测的实际标注粒度一致了。\n大 bag 是另一个工程问题。有些脚本很长，切出来的 chunk 数量会非常大，一次性送进 GPU 容易 OOM。比较稳的办法是实例累积：把一个 bag 拆成多个小批次前向传播，只保存每个 chunk 的 logit，最后再在 logit 层面做聚合。logit 很小，显存压力会低很多。\n这里有一个细节：如果先用 torch.no_grad() 跑完所有 chunk，再做 max pooling，反向传播会断掉。更可行的实现是先记录每个 chunk 的 logit 和来源，找到贡献最大值的 chunk 后，再对这个 chunk 做一次带梯度的前向传播。这样不会丢掉全部信息，也能把显存控制在可接受范围内。\n推理阶段不能简单照搬训练时的完整滑窗。对一个 1 MB 的脚本，如果 chunk 是 1 KB、步长是 512 B，模型可能要跑接近 2000 次，这在端侧开销太高。可以考虑几种折中：\n提前退出：只要某个 chunk 的概率超过高置信阈值，例如 0.95，就立即停止扫描。 前置分流：先用轻量规则筛一遍，例如是否存在高风险 API 或明显的动态执行模式，再决定是否走模型。 抽样扫描：只看头部、中部、尾部或随机抽取若干 chunk，但要接受漏掉隐藏负载的风险。 分块和 MIL 先把恶意行为的局部性放进建模假设里，再去对齐标注粒度、训练方式和推理成本。CNN 仍然只看局部窗口，聚合策略负责把局部判断还原成脚本级结论。\n","permalink":"/blog/notebook/%E6%81%B6%E6%84%8F%E8%84%9A%E6%9C%AC%E6%A3%80%E6%B5%8B%E4%B8%AD%E7%9A%84%E5%88%86%E5%9D%97%E8%81%9A%E5%90%88%E4%B8%8E%20mil/","summary":"\u003cp\u003e恶意脚本检测有一个很实际的问题：真正有问题的代码可能只占一小段。脚本整体很长，但危险行为往往集中在某几行，例如解码、下载、执行或跳转。如果直接把长脚本截断成固定长度输入，就有可能把关键负载切掉，模型最后是在一段看起来正常的文本上学习“恶意”标签。\u003c/p\u003e","title":"恶意脚本检测中的分块聚合与 MIL"},{"content":"在对抗性样本的特征工程流水线里，解析稳定性比性能更早暴露问题。最近处理一批大规模 PE 样本时，我的特征提取器偶发卡住：跑到几万个样本后，某些子进程停在原地，CPU 单核打满，tqdm 进度条也不再前进。\n这套系统用 Python 的 multiprocessing 跑样本解析，卡住的位置又落在 C++ 写的扩展库 LIEF 里。Ctrl+C 和 Python 层的异常捕获都帮不上忙：进程还活着，但执行流已经在扩展内部转不出来。下面记录这次定位过程，以及最后在工程上如何兜底。\n现象观测与现场冻结 问题看起来不像普通的 I/O 等待。挂住的进程仍在运行，CPU 满载，磁盘没有明显读写。主进程只知道某个 worker 不再返回，拿不到更细的状态。\n为了直接看子进程的调用栈，我用 py-spy 做了非侵入式 dump。遍历进程树后，对挂起的 PID 转储堆栈，位置落在这里：\nProcess 11808: ... Thread 47752 (active+gil): \u0026#34;MainThread\u0026#34; parse (pe_tool\\io.py:94) extract (pe_tool\\extractor.py:189) ... _process_sample_internal (pe_tool\\ml_feature_pipeline.py:327) 堆栈指向 lief.parse(file_path)。接下来还要确认它究竟只是很慢，还是已经不会退出。我把触发卡顿的样本单独取出，在隔离环境中复现：\npy-spy dump --pid 11808 --locals Thread 47752 (active+gil): \u0026#34;MainThread\u0026#34; parse (pe_tool\\io.py:94) Arguments: file_path: \u0026#34;genpack\u0026#34; file_data: \u0026lt;bytes at 0x20190be7040\u0026gt; 复现时，控制台打印到 Dynamic relocations 后就不再有输出：\n\u0026gt;\u0026gt;\u0026gt; lief.parse(r\u0026#34;genpack\u0026#34;) ... Dynamic relocations. arch=AMD64, version=2, size=0x8b4806eb # (在此处永久挂起) 这基本排除了 Python 层业务逻辑的问题。卡住点在 LIEF 对某个 PE 结构的解析过程中。\n源码推导与逻辑验证 既然日志最后停在 Dynamic relocations，并且带有 version=2，我去看了 LIEF 对加载配置里动态重定位表的解析。对应位置在 src/PE/LoadConfigurations/LoadConfiguration.cpp 中的 parse_dyn_relocs_entries 函数。\n这个模板函数会根据版本解析动态重定位项。受影响版本的关键逻辑可以简化成：\ntemplate\u0026lt;uint8_t version, class PE_T\u0026gt; ok_error_t LoadConfiguration::parse_dyn_relocs_entries( Parser\u0026amp; ctx, BinaryStream\u0026amp; stream, LoadConfiguration\u0026amp; config, size_t size) { const size_t stream_start = stream.pos(); // 循环终止条件：流指针位置 \u0026lt; 起始位置 + 数据块大小 while (stream \u0026amp;\u0026amp; stream.pos() \u0026lt; (stream_start + size)) { if constexpr (version == 1) { // Version 1 解析逻辑：读取数据，移动指针 auto v1 = DynamicRelocationV1::parse\u0026lt;PE_T\u0026gt;(ctx, stream); if (v1 == nullptr) break; config.dynamic_relocs_.push_back(std::move(v1)); continue; } // 问题的核心区域 if constexpr (version == 2) { continue; } } return ok(); } 这里的问题很直接：version == 2 分支只执行 continue，没有读取数据，也没有移动 stream 的位置。while 条件依赖 stream.pos() 继续向前，但指针一直停在原处，于是循环永远无法结束。这也能解释现场看到的单核 CPU 打满。\n触发样本的 PE 头声明了 version=2 的动态重定位表。LIEF 当时还没有实现这一路径，却留下了一个 continue 占位，结果在不消耗输入的情况下反复进入同一轮循环。\n解决方案与工程权衡 这个问题有两类处理：上游修复解析逻辑，以及在自己的流水线里加超时兜底。\n1. 上游修复 如果暂不支持 version=2，解析器应该明确退出当前解析，而不是在循环里继续。对应的 Issue/PR 是：\nIssue #1273 、 PR #1274：\nif constexpr (version == 2) { LIEF_WARN(\u0026#34;Dynamic Relocation Version 2 not supported yet\u0026#34;); break; // 显式跳出，而非 continue } 这个改动的关键不在警告，而在 break：不支持的结构可以跳过或报错，但不能留在同一个流位置上继续循环。\n2. 工程层面的熔断 对不可信样本做批量解析时，不能假设依赖库永远会返回。尤其是 C++ 扩展一旦在内部死循环，可能长期持有 GIL，Python 层的异常处理不会触发；父进程也很难按普通任务失败来处理。\n这里需要把解析放到可抛弃的进程里，并给每个样本设置硬超时。multiprocessing 比 threading 更适合这种场景，因为挂住的 worker 可以被父进程直接终止。一个简化的看门狗写法如下：\ndef safe_process_sample(sample_path, timeout=30): p = multiprocessing.Process(target=worker_task, args=(sample_path,)) p.start() # 阻塞主进程，直到超时或子进程结束 p.join(timeout=timeout) if p.is_alive(): # 此时已确认发生超时（可能是死循环或 IO 阻塞） print(f\u0026#34;[Warn] Process {p.pid} hung on {sample_path}, forcing termination.\u0026#34;) p.terminate() # 确保僵尸进程被回收 p.join() return None return p.exitcode 进程隔离会带来启动和 IPC 开销，但对这类处理不可信 PE 的流水线来说，这个成本通常比整批任务被一个样本拖死更可接受。最终策略是：上游修复负责消除已知 bug，本地超时机制负责兜住未知解析问题。\n","permalink":"/blog/notebook/%E5%85%B3%E4%BA%8E%20lief%20%E5%BA%93%E8%A7%A3%E6%9E%90%20pe%20%E6%96%87%E4%BB%B6%E6%AD%BB%E5%BE%AA%E7%8E%AF%E9%97%AE%E9%A2%98%E7%9A%84%E6%8E%92%E6%9F%A5%E4%B8%8E%E5%88%86%E6%9E%90/","summary":"\u003cp\u003e在对抗性样本的特征工程流水线里，解析稳定性比性能更早暴露问题。最近处理一批大规模 PE 样本时，我的特征提取器偶发卡住：跑到几万个样本后，某些子进程停在原地，CPU 单核打满，\u003ccode\u003etqdm\u003c/code\u003e 进度条也不再前进。\u003c/p\u003e","title":"基于 LIEF 的 PE 解析死循环排查"},{"content":"假设我们要构建一个 Agent，在谈 Policy 或 Action 之前，先要回答一个问题：目标怎么量化，又怎么让机器使用这个量化结果？\n在强化学习里，这个介质就是 Reward。\n奖励与轨迹 在这个框架下，我更愿意把 Reward 看成我们和 Agent 沟通的语言。我们通过设计 Reward 函数来引导 Agent 的行为。这里有一个容易忽略的点：在强化学习语境下，“没有奖励”本身也可能是一种惩罚，比如时间流逝带来的代价；反过来也一样。这些情况在数学上可以统一处理。\n为了描述 Agent 的一连串行为，我们引入了 Trajectory 的概念。它是一条由状态和动作交织而成的链条：\nτ=s0,a0,s1,a1,s2,a2,…\\tau = s_0, a_0, s_1, a_1, s_2, a_2, \\dotsτ=s0​,a0​,s1​,a1​,s2​,a2​,…面对这样一个序列，接下来的问题是：怎么评价这条轨迹好不好？这就引出了 Return。最直观的想法是把轨迹上的 Reward 全部加起来。但如果任务是无限进行的 Continuing Task，无穷多个正数相加可能导致级数发散，后续分析会变得很麻烦。\n为了解决这个问题，同时表达“越近越重要”的时间偏好，需要引入折扣因子 γ\\gammaγ，定义折扣回报：\nGt=Rt+1+γRt+2+γ2Rt+3+⋯=∑k=0∞γkRt+k+1G_t = R_{t+1} + \\gamma R_{t+2} + \\gamma^2 R_{t+3} + \\dots = \\sum_{k=0}^{\\infty} \\gamma^k R_{t+k+1}Gt​=Rt+1​+γRt+2​+γ2Rt+3​+⋯=k=0∑∞​γkRt+k+1​这里的 γ\\gammaγ 处于 000 到 111 之间。它控制了 Agent 的“视野”：\n当 γ→0\\gamma \\to 0γ→0 时，Agent 变得很短视，只在乎当前的 Rt+1R_{t+1}Rt+1​；\n当 γ→1\\gamma \\to 1γ→1 时，Agent 更重视长期回报，愿意为了未来收益牺牲当下收益。\n为了统一处理有限的 Episode 和无限的 Continuing Task，可以使用一个小技巧：对 Episode 任务，假设终止状态之后 Agent 进入一个特殊的 Absorbing State，它只会不断回到自身，并且 Reward 永远为 0。这样所有任务都可以视为无限长序列，公式推导会统一很多。\n必要的假设：马尔可夫性质 构建数学模型时，需要一个容器来承载这些元素，这就是 MDP。它包含 (S,A,P,R,γ)(S, A, P, R, \\gamma)(S,A,P,R,γ)。\n其中关键是 Markov 性质，也就是“历史无关性”：\nP(St+1∣St)=P(St+1∣St,St−1,…,S0)P(S_{t+1} | S_t) = P(S_{t+1} | S_t, S_{t-1}, \\dots, S_0)P(St+1​∣St​)=P(St+1​∣St​,St−1​,…,S0​)未来状态只取决于当前状态和动作，而与更早的历史无关。\n第一次接触这个概念时，我其实很排斥。直觉上，“历史”当然重要。但在建模里，如果必须考虑所有历史信息，状态空间会指数级膨胀，计算上基本不可行。\n因此，接受马尔可夫性质更像是为了计算可行性做出的建模选择。幸运的是，在很多物理问题里，如果状态定义足够完整，比如包含位置、速度、加速度，当前状态确实已经包含了预测未来所需的大部分信息。\n贝尔曼方程：递归的本质 有了 MDP 和回报定义，就可以写出 Bellman 方程。\n状态价值函数 v(s)v(s)v(s) 表示从状态 sss 出发能获得的期望回报。直接展开求和会很繁琐，但用递归写法会简单很多：\nv(s)=E[Rt+1+γv(St+1)∣St=s]v(s) = \\mathbb{E} [R_{t+1} + \\gamma v(S_{t+1}) | S_t = s]v(s)=E[Rt+1​+γv(St+1​)∣St​=s]这个方程说的是：当前状态的价值，等于即时奖励加上未来状态价值的折扣期望。\n[!note] 在推导贝尔曼方程的过程中，必须要理解概率论中的 全期望公式。\n求解：从方程到矩阵 基于上述理解，我们可以把贝尔曼方程写成更具体的形式：\nv(s)=Rs+γ∑s′Pss′v(s′)v(s) = \\mathcal{R}_s + \\gamma \\sum_{s\u0026#x27;} P_{ss\u0026#x27;} v(s\u0026#x27;)v(s)=Rs​+γs′∑​Pss′​v(s′)仔细看，这就是一个线性方程组。如果把所有状态价值排成列向量 vvv，对整个状态空间可以写成矩阵形式：\nv=R+γPvv = \\mathcal{R} + \\gamma P vv=R+γPv其中 PPP 是状态转移矩阵。这个形式和线性代数里的 Ax=bAx=bAx=b 很像，可以直接通过矩阵运算求解：\n(I−γP)v=R ⟹ v=(I−γP)−1R(I - \\gamma P) v = \\mathcal{R} \\implies v = (I - \\gamma P)^{-1} \\mathcal{R}(I−γP)v=R⟹v=(I−γP)−1R结果与权衡 虽然数学上给出了闭式解，但实际工程里，直接求逆矩阵的代价通常不可接受。\n矩阵求逆的时间复杂度通常是 O(n3)O(n^3)O(n3)。\n如果状态空间 nnn 是 1000，计算尚可接受。\n但对于围棋（1017010^{170}10170）或复杂机器人控制任务，这种方法很快失效。\n这也是后续算法需要引入动态规划、蒙特卡洛、时序差分等迭代方法的原因：它们都在避开昂贵的矩阵求逆，用更低的计算成本逼近 vvv。\n写在最后 计算 v(s)v(s)v(s) 最后还是为了决策。如果知道所有状态的价值，就可以在每个状态选择通向高价值状态的动作。但这建立在已知环境模型（PPP 和 RRR）的假设上。如果环境模型未知，就需要进入无模型强化学习的问题。\n","permalink":"/blog/notebook/%E5%BC%BA%E5%8C%96%E5%AD%A6%E4%B9%A0%E5%85%A5%E9%97%A8%E4%B9%8B%E8%B7%AF0/","summary":"\u003cp\u003e假设我们要构建一个 Agent，在谈 Policy 或 Action 之前，先要回答一个问题：目标怎么量化，又怎么让机器使用这个量化结果？\u003c/p\u003e\n\u003cp\u003e在强化学习里，这个介质就是 Reward。\u003c/p\u003e\n\u003ch2 id=\"奖励与轨迹\"\u003e奖励与轨迹\u003c/h2\u003e\n\u003cp\u003e在这个框架下，我更愿意把 Reward 看成我们和 Agent 沟通的语言。我们通过设计 Reward 函数来引导 Agent 的行为。这里有一个容易忽略的点：在强化学习语境下，“没有奖励”本身也可能是一种惩罚，比如时间流逝带来的代价；反过来也一样。这些情况在数学上可以统一处理。\u003c/p\u003e","title":"强化学习基础：从回报定义到贝尔曼方程的推导"},{"content":"jyy 在操作系统课上讲到这部分时，对我冲击很大。它让我重新想了一遍：程序到底是什么，函数调用到底发生了什么。\n我以前一直觉得自己知道什么叫递归，也能手写一些把递归转换成非递归的算法。但很多时候，这只是学会了别人怎么做，并没有真正想清楚为什么能这么做。从汇编和运行时栈的角度看，这个问题会更具体。\nFibonacci 数列示例：栈帧模拟 用栈模拟运行时栈时，需要站在汇编语言的角度思考。函数运行过程中产生的临时变量不能再被忽略，它们也要明确落到状态里，比如下面代码中的 t1、t2。\ntypedef struct Frame { int pc, n, rv, t1, t2; //pc指示当前栈帧运行到了哪一步 } Frame; // 一个栈帧, 由于是递归调用, 栈帧的大小就是确定的. #define call(...) ({ *(++top) = (Frame){.pc = 0, __VA_ARGS__}; }) #define goto(loc) ({ f-\u0026gt;pc = (loc)-1; }) #define ret() ({ top--; }) int fibs(int n) { Frame stk[64], *top = stk - 1; int rax = 0; //模拟rax寄存器, 保存函数返回值 call(n); for (Frame *f; (f = top) \u0026gt;= stk; f-\u0026gt;pc++) { //在switch中, 每一个case相当于对应的递归函数中的一行. //但需要注意的是, 临时变量需要显式的表示出来. switch (f-\u0026gt;pc) { case 0: if (f-\u0026gt;n == 1 || f-\u0026gt;n == 2) { f-\u0026gt;rv = 1; goto(5); } break; case 1: call(f-\u0026gt;n - 1); break; case 2: f-\u0026gt;t1 = rax; break; case 3: call(f-\u0026gt;n - 2); break; case 4: f-\u0026gt;t2 = rax; break; case 5: if (f-\u0026gt;rv == 0) f-\u0026gt;rv = f-\u0026gt;t1 + f-\u0026gt;t2; rax = f-\u0026gt;rv; ret(); //由于在递归函数中有两种返回方式, 所以这里需要判断一下是哪一种 break; } } return rax; } Reference 可以点击这里看一个视频\n","permalink":"/blog/notebook/%E4%BB%8E%E9%80%92%E5%BD%92%E5%88%B0%E9%9D%9E%E9%80%92%E5%BD%92/","summary":"\u003cp\u003ejyy 在操作系统课上讲到这部分时，对我冲击很大。它让我重新想了一遍：程序到底是什么，函数调用到底发生了什么。\u003c/p\u003e\n\u003cp\u003e我以前一直觉得自己知道什么叫递归，也能手写一些把递归转换成非递归的算法。但很多时候，这只是学会了别人怎么做，并没有真正想清楚为什么能这么做。从汇编和运行时栈的角度看，这个问题会更具体。\u003c/p\u003e","title":"从递归到非递归"},{"content":"","permalink":"/graph/","summary":"Explore connections between published posts","title":"Graph"}]