# 造壳 MkShell:完整文章上下文 > 这是 https://www.mkshell.com/llms.txt 的扩展文件,按文档边界收录所有已发布文章的 Markdown 正文。 https://www.mkshell.com/posts/claude-ai-watermark-provenance/ https://www.mkshell.com/posts/claude-ai-watermark-provenance.md 2026-08-12 # Claude 给 AI 文本加水印,就能给内容办身份证吗? 一篇文章经过 Claude 起草、人工补充案例、ChatGPT 润色标题,再被翻成另一种语言。最后它出现在你的邮箱、学校作业系统或新闻网站上。 这时看到一个「由 Claude 生成」的标记,到底意味着什么?是找到了作者,证明了作弊,还是只是在内容上留下了一枚很容易丢失的标签? Anthropic 在 [How Claude marks AI-generated content](https://support.claude.com/en/articles/16266773-how-claude-marks-ai-generated-content) 中给出的方向,是让支持的 Claude 输出携带两类来源信号:文本中的不可见水印,以及适用文件格式中的带签名来源信息。它有价值,但不该被理解成 AI 内容的终局判官。 先说结论:**水印和签名来源信息最擅长回答「这份仍保留信号的内容,是否来自参与标记的生成器」;它们不擅长回答「谁是作者」「AI 写了百分之多少」或「这个人是否违规」。** 把前一个问题做好,已经比凭文风猜测进了一大步;拿它越过后面几条边界,就会把技术证据误用成道德或纪律结论。 > **数据快照** > > - 最后核验:2026-08-12 > - 讨论对象:Anthropic 的 Claude 内容标记说明;不把它外推为所有 Claude 输出、所有平台或所有 AI 模型的统一行为 > - 对照来源:Google DeepMind 的 [SynthID 文本水印说明](https://deepmind.google/blog/watermarking-ai-generated-text-and-video-with-synthid/)、[C2PA 2.4 规范](https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html)、OpenAI 的 [content provenance 更新](https://openai.com/index/understanding-the-source-of-what-we-see-and-hear-online/) > - 证据限制:本文没有自行生成 Claude 样本或攻击水印;效果、误报率和质量影响以公开机制说明为边界,不把厂商描述写成独立 Benchmark ## 先把三个问题拆开:像不像、从哪来、谁负责 AI 内容讨论里最容易混在一起的,是三个完全不同的问题。 「检测器」通常在内容生成之后看文本特征,推测它**像不像**某类 AI 输出;「文本水印」在生成阶段嵌入统计信号,检测时验证它**是否带有某个参与方的标记**;「签名来源信息」则把关于工具和编辑历史的声明绑定到文件,供验证者检查**声明由谁签发、内容是否被篡改**。 **表 1:AI 内容识别的三种信号;比较的是机制边界,不是三者的公开准确率排名** | 方法 | 信号从哪里来 | 阳性结果最稳妥的含义 | 阴性结果不能说明什么 | 最常见的脆弱点 | | ------------ | ------------------------------ | -------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------- | | 文风分类器 | 生成后从文本或图像特征推断 | 内容与训练时见过的 AI 模式相似 | 不能证明内容完全由人类创作 | 换模型、换题材、人工改写都会影响分布与误判 | | 文本水印 | 模型生成时留下可检验的统计模式 | 检测到的片段很可能包含该标记生成器的输出 | 未检出不等于没有使用 AI,也不等于人类原创 | 深度改写、翻译、重生成会削弱或抹掉信号 | | 签名来源信息 | 工具对来源与编辑声明做加密签名 | 可验证声明来自持有签名凭据的一方,且绑定内容未被悄悄改动 | 元数据缺失不等于文件没有 AI 参与 | 不支持的工具链可能移除信息;签名只能证明声明与签发者,不替人判断真实性 | 这个区分不是术语洁癖。Google 对 SynthID 的公开解释显示,文本水印可以通过调制候选 token 的概率分布嵌入;它的检测器比较输出模式和预期水印模式。[这套机制说明](https://deepmind.google/blog/watermarking-ai-generated-text-and-video-with-synthid/#how-text-watermarking-works)能解释为什么水印不是「复制一个隐藏字符」的把戏,却**不能**证明 Claude 使用了同一算法或有相同指标。 而 C2PA 所谓 Content Credentials,是由断言、声明和数字签名组成的可验证清单;信任基础是签名者身份和对应的签名密钥。[规范把它定位为来源与编辑历史的证据](https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#technical-overview),不是「这份内容真实、善意、合法」的证明书。 ## 一条内容离开模型以后,信号会经过哪些关卡 把它想成内容的身份证不算离谱,但最好补上一句:这张身份证只能证明发证机关签过什么,并不能在任何复印、改写和转载之后自动跟着人跑。 **图 1:本文解读|生成端信号从 Claude 输出到下游验证的两条路径** ```mermaid flowchart LR S([开始:模型生成内容]) --> A[文本携带统计水印] S --> B[文件携带签名来源信息] A --> C[复制、引用或轻度编辑] B --> D[支持来源信息的平台与工具] C --> E{水印仍可检出?} D --> F{签名与内容绑定仍有效?} E -- 是 --> G[得到参与生成的正向证据] E -- 否 --> H[无法据此判定来源] F -- 是 --> G F -- 否 --> H G --> Z([结束:交给人按场景判断]) H --> Z ``` 图里的两个「否」都很关键。它们不是「抓到了违规者」,也不是「已经洗白」。它们只是说:现有信号不足以再做来源判断。 对于文本,长且有多种可选表达的输出,留给水印调整的空间更大;固定事实问答或几乎只有一种正确写法的内容,空间更小。Google 还明确列出了翻译和彻底改写会显著降低 SynthID 置信度的边界。[它的限制说明](https://deepmind.google/blog/watermarking-ai-generated-text-and-video-with-synthid/#the-benefits-and-limitations-of-this-technique)恰好说明了一个更普遍的事实:生成端标记是一种**正向归因信号**,不是不可删除的内容 DNA。 对于文件,签名元数据比普通 EXIF 更难被伪造或无痕改写,却仍依赖保存和验证这条链路。C2PA 甚至专门定义了把水印或指纹作为 soft binding 的方式,用于在清单与文件分离时尝试重新匹配;同一份规范也强调,soft binding 不是 hard binding 的替代品。[这个设计](https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html#soft-bindings)本身就承认「文件在传播中丢失元数据」是正常风险,而不是靠一次签名就能消失的问题。 ## 「Claude 生成 80%,人工 20%」不是水印能直接给出的结论 对话里最诱人的应用是企业治理:员工交来一份方案,系统显示其中有 Claude 来源信息,于是把它翻译成「Claude 生成 80%,人工修改 20%」。 这一步跨得太远了。 水印检出最多支持「某一段仍保留了该模型的可检验信号」。它不会自动知道提纲是谁定的、事实是谁核的、哪几段是人工重写,也不知道用户是否只是让模型润色了一句话。即使未来产品展示了命中片段或置信度,那也仍是针对特定检测器、特定文本和特定阈值的结果,不是创作贡献的通用百分比。 学校场景同样如此。发现来源信号可以触发一次更透明的对话:学生是否按课程规则申报了 AI 辅助?使用范围是否允许?但它不能单独证明「代写」或「作弊」。反过来,没有检出也不能替学生背书:内容可能来自不参与标记的模型、经过翻译重写,或根本没有留下可检测信号。 这也是为什么水印比分类器更适合做**审计入口**,而不适合做自动处分按钮。 ## 对模型质量的担心合理,但现在还不是结论 「改变 token 概率,就一定会伤害文本质量」听起来像一道很直观的数学题。更准确的说法是:水印机制确实会改变采样分布,因此需要在信号强度、可检测性、鲁棒性与生成质量之间做工程取舍;但仅从「分布被调整」这一点,不能推出某个模型、某种任务或某段长文本必然出现可感知的质量下降。 Google 在 SynthID 说明中主张其方法不影响质量、准确性、创意或速度;这是厂商对自家方案的描述,不是 Claude 的独立结论,也不等于所有提示词和所有语言都已经测过。另一方面,OpenAI 在其文本水印研究更新中承认,局部改写下水印可能仍有效,但翻译、另一模型的重写等全局扰动会使它容易被规避,并提出潜在的不均衡影响问题。[这些取舍](https://openai.com/index/understanding-the-source-of-what-we-see-and-hear-online/)说明,真正值得看的不是一句「有无 trade-off」,而是公开的分任务质量评测、误报率、漏报率、不同语言和对抗改写下的曲线。 在 Anthropic 没有公开这些可复核指标前,最诚实的结论只能是:**机制上存在需要验证的取舍,实际影响尚不能仅靠直觉定量。** ## 这不是反作弊软件,更像一层缺了生态就不工作的基础设施 把内容溯源比作 HTTPS,有一点道理:它提供的是一层可以验证的身份与完整性信号,而不是替用户判断网页上的每一句话是否可信。不过这个类比也不能走太远。HTTPS 的浏览器、证书机构和服务器已经形成基础设施;AI 来源信号要发挥作用,同样需要模型厂商、编辑工具、发布平台、验证器和规则制定者一起保留并展示它。 **本文推断**:这也是厂商积极布局来源技术的商业价值所在。企业客户会更愿意为可审计、可说明的工作流付费;如果监管或平台规则要求标识,先拥有签名、验证和合规团队的公司也更容易满足要求。这个推断不等于证明 Anthropic 的动机只是筑护城河。透明度与商业利益可以同时存在,关键是规则不能只让已有大型厂商容易通过。 更稳妥的落地方式至少有四层: - 生成工具提供可验证的水印或签名声明; - 编辑与发布工具尽量保留来源链,并清楚显示何时已断开; - 平台将来源信号、投诉与人工审核结合,而不是把标签当成真假裁判; - 学校和公司提前写清允许的 AI 辅助范围、申报方式与复核流程。 少了后面三层,水印只是发证;有了四层,它才可能成为可用的治理接口。 ## 总结:把「有标记」当作线索,而不是判决 Claude 的内容标记方向值得支持,因为它把问题从「这篇文字像不像 AI」往「是否有可验证的生成来源」推进了一步。对仍保存信号的原始输出,这种正向证据通常比从文风反推更有意义。 但请记住它的边界:水印可能在改写中失效;签名元数据可能在工具链中脱落;两者都不能量化人机贡献,更不能独立判断作弊、版权、诈骗或内容真假。 所以下次看到「AI 生成」标签,可以先问四个问题:它标记的是哪一段?由谁、以什么方式验证?经过哪些编辑和转载?系统规则打算据此做什么? 能回答这四个问题,来源信号才是在帮我们理解内容;否则,它很容易从一条有用的技术线索,变成一枚看起来很科学的裁决印章。 ## 参考资料 - [Anthropic:How Claude marks AI-generated content](https://support.claude.com/en/articles/16266773-how-claude-marks-ai-generated-content),2026-08-12 核验 - [Google DeepMind:Watermarking AI-generated text and video with SynthID,2024-05-14](https://deepmind.google/blog/watermarking-ai-generated-text-and-video-with-synthid/) - [C2PA:Content Credentials 2.4 Specification](https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html),2026-08-12 核验 - [OpenAI:Understanding the source of what we see and hear online,2024-05-07;2024-08-04 更新](https://openai.com/index/understanding-the-source-of-what-we-see-and-hear-online/) https://www.mkshell.com/posts/maibot-companion-agent-source-reading/ https://www.mkshell.com/posts/maibot-companion-agent-source-reading.md 2026-08-12 # MaiBot 不是来替你办事的:一次陪伴型 Agent 的源码拆解 很多智能体(Agent)的第一句潜台词是:“把任务给我。”MaiBot 的第一句更接近:“我现在该不该接这句话?” 这两个问题看起来只差一点,做出来却会长成两种产品。前者把模型、工具和工作流拧成一台办事机器;后者得先处理群聊节奏、关系历史、说话方式,甚至“这次沉默是不是更像一个人”。MaiBot 并不是因为接了一个大语言模型(LLM)就特别,它特别的地方是把优化目标放在了**拟人化的长期陪伴对话**,并把这个目标拆进了回复时机、人格配置、表达学习和长期记忆里。 ## 先说结论:它是陪伴优先的聊天 Agent,不是通用任务 Agent 的平替 如果要用一句话概括:MaiBot 的核心不是“会不会调用工具”,而是“在多人聊天这个持续场景里,如何决定要不要说、以什么关系和口吻说、又该记住什么”。官方 README 直接把设计原则写成“最像而不是好”,并把自然对话、适时发言、模仿群体语言、了解用户和插件系统列为产品能力。[README 的定位说明](https://github.com/Mai-with-u/MaiBot/blob/ceeadb8f8297e1761c24c7634ef9e4118ad14cbf/README.md#L27-L41) 这不意味着它在所有任务上都优于其他 Agent。需要稳定完成检索、写代码、操作浏览器或跑业务流程时,任务型 Agent 的目标函数更直接,也通常更容易评测。MaiBot 的取舍是:把预算和复杂度给“关系连续性”与“对话自然度”,而不是把每一轮都压成一次高效的问答。 > **数据快照** > > - 最后核验:2026-08-12 > - 源码范围:`Mai-with-u/MaiBot` 的 `main` 分支头 `ceeadb8f8297e1761c24c7634ef9e4118ad14cbf` > - 文档页面日期:功能介绍与 A_Memorix 配置页均标注更新于 2026-08-06;其余官网页面的日期可能不同,均以各页为准(下列官网链接访问于 2026-08-12) > - 比较边界:本文比较设计重心与公开实现,不宣称聊天质量、记忆准确率或工具成功率的实测排名。 **表 1:MaiBot 与常见任务型 Agent 的设计重心对比;前者为源码/官方文档可见能力,后者是“以完成外部任务为主要目标”的通用类别,不是基准测试结果** | 维度 | MaiBot | 常见任务型 Agent | | ---------- | ------------------------------------ | ------------------------------------ | | 首要问题 | 现在是否该说话、怎样更像群聊成员 | 怎样分解并完成用户目标 | | 核心状态 | 聊天流、人物画像、长期记忆、群体表达 | 任务计划、工具状态、工作区与任务产物 | | 成功信号 | 节奏自然、上下文连贯、个性化不过度 | 正确性、完成率、耗时、成本、可复现性 | | 工具的角色 | 扩展对话中的能力边界 | 推进任务的主要执行手段 | | 主要风险 | 记忆错误、人格越界、错误时机打断对话 | 计划跑偏、工具失败、结果不可验证 | 表里的第二列是产品定位与公开实现的归纳;第三列是分析用的理想类型,不应理解成“所有其他 Agent 都一样”。 ## 一条回复不是直接生成,而是先过一遍“该不该开口” MaiBot 的功能文档将 `Maisaka` 描述为“基于工具调用的多轮内部推理系统”。其中,Planner 是决定下一步行动的规划器;聊天管线还包含 Hook(钩子)、命令分发、过滤与路由。[功能介绍(访问于 2026-08-12)](https://docs.mai-mai.org/features/) 这已经和“收到消息,拼提示词(prompt),输出文本”的直线模型拉开了距离。 源码目录也提供了相应的边界:消息接收层有 `bot.py`、`chat_manager.py` 和发送器;回复层有 `maisaka_generator.py`、表达索引和选择器;心流层有 `heartflow_manager.py` 与消息处理器。[消息接收目录](https://github.com/Mai-with-u/MaiBot/tree/ceeadb8f8297e1761c24c7634ef9e4118ad14cbf/src/chat/message_receive) [回复目录](https://github.com/Mai-with-u/MaiBot/tree/ceeadb8f8297e1761c24c7634ef9e4118ad14cbf/src/chat/replyer) [心流目录](https://github.com/Mai-with-u/MaiBot/tree/ceeadb8f8297e1761c24c7634ef9e4118ad14cbf/src/chat/heart_flow) **图 1:模块与数据流的分析性重建。虚线只表达本文对公开功能说明、配置项与目录边界的综合解读,不是逐行源码确认的调用图;规划器(Planner)的具体提示词(prompt)、评分细节及每条调用边均不在本文断言范围内。** ```mermaid flowchart LR A[即时通信 IM 适配器或 WebUI] -. 分析性数据流 .-> B[消息接收与聊天流] B -.-> C[心流会话:时机与频率] C -.-> D[Maisaka Planner] D -.-> E[记忆/画像查询与工具调用] E -.-> F[回复生成与表达选择] F -.-> G[发送回复] G -.-> H[摘要与人物事实回写] H -.-> I[A_Memorix:检索、画像、关系] I -.-> D ``` 这张图最重要的不是“模块很多”,而是闭环位置:回复后还有写回,下一次 Planner 之前又有画像注入或查询。少了这两步,所谓“越来越懂你”通常只是一段越来越长的聊天记录。 `heartflow_manager.py` 是追溯心流实现的固定源码入口;本文仅据此和公开功能说明确认存在心流模块与“发言时机/频率”这一产品概念,不再把未经逐行复核的会话上限、活跃窗口或淘汰算法写成事实。[`heartflow_manager.py`(固定 SHA)](https://github.com/Mai-with-u/MaiBot/blob/ceeadb8f8297e1761c24c7634ef9e4118ad14cbf/src/chat/heart_flow/heartflow_manager.py) 因此,它不能证明 Planner 在每个群里都能正确读懂气氛;后者需要真实会话数据与人工评测,仓库没有给出可复现的对比基准。 ## “记得你”不是把聊天记录塞回上下文 MaiBot 当前的长期记忆子系统叫 A_Memorix。官方配置将它定义为存储、向量化、检索、人物画像、记忆演化和 Web 运维的集合;在聊天集成层,可以启用记忆查询工具、人物画像查询/注入、人物事实写回与按消息窗口写回聊天摘要。[A_Memorix 配置(访问于 2026-08-12)](https://docs.mai-mai.org/manual/configuration/amemorix-config) 这套设计至少分开了四件通常会被“记忆(memory)”一个词糊在一起的事: - 当前聊天流保存短期上下文; - 对话窗口被摘要并写入长期记忆; - 人物事实和偏好被抽取为画像; - 回答前检索记忆或把相关画像注入 Planner。 仓库内 A_Memorix 的 README 列出 `search_memory`、`ingest_text`、`get_person_profile`、`maintain_memory` 等工具(Tool),以及 `search/time/hybrid/episode/aggregate` 检索模式。[A_Memorix 模块说明](https://github.com/Mai-with-u/MaiBot/tree/ceeadb8f8297e1761c24c7634ef9e4118ad14cbf/src/A_memorix) 这说明它不是一个只做向量相似度搜索的小盒子:关系、情节记录(Episode)、画像和维护操作都被放进同一个运行时。项目依赖还声明了 `faiss-cpu`、`numpy`、`SQLAlchemy`、`SQLModel`、`jieba` 和 `ahocorasick-rs`,与“向量检索 + 结构化存储 + 中文文本处理”的实现路线相符。[`pyproject.toml`](https://github.com/Mai-with-u/MaiBot/blob/ceeadb8f8297e1761c24c7634ef9e4118ad14cbf/pyproject.toml#L3-L45) 但要把边界划清楚:依赖清单只能证明项目声明了这些库,模块文档能证明公开接口与配置意图;它们都不足以证明某个嵌入模型(embedding model)、向量维度、稀疏检索策略或召回质量在任意部署上都相同。事实上,官方文档明确要求启用记忆前先配置嵌入模型,并将记忆系统默认设为关闭。[部署概览(访问于 2026-08-12)](https://docs.mai-mai.org/manual/deployment/) [记忆开关说明(访问于 2026-08-12)](https://docs.mai-mai.org/manual/configuration/amemorix-config) ## 人格、表达学习和工具不是三份孤立的配置 人格配置不是一句装饰性的系统提示词(system prompt)。`bot_config.toml` 的公开说明中,`personality` 定义身份特征,`reply_style` 定义默认表达,`multiple_reply_style` 可以按概率替换临时风格。[Bot 配置中的人格部分(访问于 2026-08-12)](https://docs.mai-mai.org/manual/configuration/bot-config) 而回复目录同时存在表达向量索引与表达选择器;这支持一个谨慎的推断:项目至少试图把“说什么”和“怎么说”拆开处理,而不是完全交给一次生成。 “谨慎”二字很重要。目录命名无法证明具体的向量模型、筛选公式或命中率,我没有把这些未读到的实现细节写成结论。 在工具与模型扩展上,官方功能页明确列出模型上下文协议(MCP)集成;`pyproject.toml` 同时声明 `mcp`、`fastapi`、`playwright`、`openai` 与 `google-genai` 等依赖。[MCP 功能说明(访问于 2026-08-12)](https://docs.mai-mai.org/features/) [依赖清单(固定 SHA)](https://github.com/Mai-with-u/MaiBot/blob/ceeadb8f8297e1761c24c7634ef9e4118ad14cbf/pyproject.toml#L8-L45) 依赖声明能支持“可插拔模型/工具生态”的判断,却**不能单独证明**运行时一定采用某种异步服务架构;本文不再把后者写成结论。可以确认的是:它把“陪聊”放在产品层,并非另造了一种基础模型。 ## 真正的代价:越像人,越要能被纠正 把记忆、画像和自动写回接到对话里,会得到连续性,也会放大错误:错误事实可能在下一次回复前被注入;过强的模仿会显得冒犯;错误的“主动发言”比没有回复更打扰人。 MaiBot 至少公开了几项纠偏入口:Web 管理界面(WebUI)可以查看和管理长期记忆、画像与来源;配置可以关闭查询、画像注入和自动写回。[记忆系统说明(访问于 2026-08-12)](https://docs.mai-mai.org/manual/features/memory-system) 对部署者而言,这比“模型有长期记忆”更值得先看:先决定哪些群、哪些人、哪些数据允许写入,再调说话频率和检索参数。不要反过来,把一段看起来很像人的回复,当成它已经可靠理解了你的证据。 ## 验证结果与仍未确认的结论 这次核查确认了四件事:MaiBot 的官方定位确实以拟人化对话和适时发言为中心;公开目录与源码入口中确实存在会话级心流、回复生成、表达选择与 A_Memorix 模块;长期记忆的查询、画像注入与写回有明确配置;项目依赖能支持大语言模型、嵌入模型、向量检索、模型上下文协议与 Web 管理界面的实现路线。 我没有确认、也不应该从这些资料推出的结论包括:它比任一具体智能体“更像人”或“更聪明”;规划器的完整提示词、工具选择策略和表达向量算法;不同即时通信适配器在生产环境中的一致性;记忆检索的准确率、延迟与隐私风险的量化结果。源码快照能解释结构,不能替代端到端评测。 如果你在选型,把问题换成一句更有用的话:你要的是一个替你把事情做完的智能体,还是一个能在群里长期相处、但需要认真配置边界和纠错入口的数字成员?前者先看任务闭环与可验证性,后者再看 MaiBot 的心流、画像和记忆治理。它们并不互相替代。 ## 参考资料 - [MaiBot README:定位、能力与设计理念](https://github.com/Mai-with-u/MaiBot/blob/ceeadb8f8297e1761c24c7634ef9e4118ad14cbf/README.md) - [MaiBot 功能介绍:消息管线、Maisaka、记忆、学习和 MCP(访问于 2026-08-12)](https://docs.mai-mai.org/features/) - [A_Memorix 配置:记忆查询、画像注入和自动写回(访问于 2026-08-12)](https://docs.mai-mai.org/manual/configuration/amemorix-config) - [`heartflow_manager.py`:心流实现入口(固定 SHA)](https://github.com/Mai-with-u/MaiBot/blob/ceeadb8f8297e1761c24c7634ef9e4118ad14cbf/src/chat/heart_flow/heartflow_manager.py) - [A_Memorix README:模块公开说明(固定 SHA)](https://github.com/Mai-with-u/MaiBot/tree/ceeadb8f8297e1761c24c7634ef9e4118ad14cbf/src/A_memorix) - [`pyproject.toml`:项目依赖声明(固定 SHA)](https://github.com/Mai-with-u/MaiBot/blob/ceeadb8f8297e1761c24c7634ef9e4118ad14cbf/pyproject.toml) - [MaiBot 源码快照 `ceeadb8`](https://github.com/Mai-with-u/MaiBot/tree/ceeadb8f8297e1761c24c7634ef9e4118ad14cbf) https://www.mkshell.com/posts/multica-runtime-security-boundary/ https://www.mkshell.com/posts/multica-runtime-security-boundary.md 2026-08-12 # Multica Runtime 到底拦了什么:一次危险操作会经过哪些边界? 我一开始问的问题很直接:如果我通过 Multica 让 Agent 做一件危险的事,它会在哪一步被拦下来? 答案比“会”或“不会”复杂,但也更有用:Multica Runtime 不是一个拦截所有 shell 命令的安全沙箱。它主要负责把任务送到一台已连接的机器、以任务身份调用本地 AI 编程工具,并把结果带回 Issue。真正能否读文件、起进程、访问网络、调用云 CLI,主要仍由运行 daemon 的操作系统用户和所选工具配置决定。 这不是一个缺点的委婉说法,而是部署 Agent 时最重要的威胁建模前提。把这条边界看清楚,才知道哪些控制已经生效,哪些必须自己补上。 ## 先给结论:安全控制分成两类 第一类是 Multica 明确提供的平台与任务边界:谁能运行 Agent、任务如何排队、任务通过哪个 runtime 执行、任务调用 Multica API 时以什么身份出现。这些控制对“越权操作工作区”和“不同任务互相冒充”很关键。 第二类是宿主机边界:文件、进程、网络、HOME 下已有的凭据,以及 `gh`、`aws`、`kubectl` 之类宿主 CLI 的能力。这里不能默认有 Multica 的通用拦截。官方安全模型写得很直白:默认情况下,任务拥有运行 daemon 的 OS 用户所拥有的权限;应将隔离放在 daemon 外部,而不是把 Multica 当作文件系统沙箱。[Multica Security model](https://multica.ai/docs/security-model) 所以,“危险操作会不会被拦住”要先改写成一个更准确的问题:**它走的是 Multica API,还是本地工具链?运行时所在的 OS 用户本来又拥有什么权限?** ## 一条任务是怎样落到本机上的 以 Issue 指派或评论中的 `@Agent` 为例,流程可以概括为: **图 1:从任务触发到结果回写的本地执行链路** ```mermaid flowchart LR S([开始:Issue 或评论触发]) --> A[服务端创建任务] A --> B[在线 runtime 认领] B --> C[daemon 创建工作目录] C --> D[AI 工具执行] D --> E[结果写回 Issue] E --> F([结束:记录执行结果]) ``` 任务是在连接的机器上执行;服务端负责记录、调度和回写,而不直接代替本机执行命令。 这不是推测。Multica 的公开文档把任务状态列为 `queued`、`dispatched`、`waiting_local_directory`、`running`、`completed`、`failed` 与 `cancelled`,并说明 runtime 认领任务后调用 Agent 配置所绑定的本地 AI 编程工具。[Tasks](https://multica.ai/docs/tasks) 任务的触发源可以是指派、评论提及、聊天或自动化;一次提及不是普通通知,而是一次新的执行触发。[Agents](https://multica.ai/docs/agents) 这里第一个可见的阻拦点是**平台授权**:Agent 的 Access 配置决定哪些成员可以触发该 Agent;工作区成员、管理员与 Agent owner 的权限不同。它不限制 Agent 已经运行后能访问哪些资源或命令;后者由 daemon 用户、宿主机和工具配置决定。第二个是**runtime 可用性**:没有匹配的在线 runtime,任务只能等待或失败,而不会凭空在 Multica 服务端执行。Multica 的工作模型是“服务端协调,连接的计算机执行”;本地 AI 工具的凭据和实际命令执行留在连接的机器上。[How Multica works](https://multica.ai/docs/how-multica-works) ## 真正被隔离的是什么 在任务内部,Multica 确实做了几件很有价值的隔离工作: - 每个任务有独立工作目录,减少并发任务碰撞; - Codex 任务有任务级 `CODEX_HOME`,使配置、会话和 skills 不污染用户的 `~/.codex/`; - 注入给任务的 `MULTICA_TOKEN` 绑定到特定 Agent 与特定任务,不能通过 Multica API 直接冒充人或其他 Agent。 这些是“任务身份”和“协作面”的隔离,而不是对恶意进程的安全封装。换言之,任务 token 的范围收窄了,并不自动收窄同一个进程可读的本地文件、环境变量或第三方 CLI 凭据。[Multica Security model](https://multica.ai/docs/security-model) 这也是为什么一个看起来无害的动作,要区分两条路径: **表 1:不同操作所经过的主要控制;用于区分平台任务身份与宿主机权限,不表示所有部署的固定能力** | 请求 | 主要经过的控制 | 不能据此推断的事 | | --------------------------- | --------------------------------------------- | ---------------------------------- | | “修改 Issue 状态、添加评论” | 任务 token、Agent/工作区权限、服务端 API 校验 | 不代表本机文件也被限制 | | “删除仓库中的文件” | OS 用户权限、Codex/工具的 sandbox 与审批配置 | 不代表 Multica 平台 API 会允许越权 | | “调用 `aws` 或 `kubectl`” | OS 用户可用凭据、网络与云 IAM | 不代表任务级 token 具有云权限 | | “联网检索或下载依赖” | Provider 能力、宿主网络策略和工具配置 | 不代表所有运行时都有相同网络策略 | ## 我们实测到的边界:临时目录、系统信息与网络 以下内容来自一次受控实测。为避免动到机器原有文件,测试只在 `/tmp` 下创建随机的新目录,在其中完成“新建—读取—修改—删除”,随后确认测试文件和目录均不存在。四步都成功。 这能证明的只有一件事:当前受管 runtime 允许 Agent 在可写临时路径执行常规文件系统操作,**没有**对这类通用生命周期做额外阻断。它不能证明任何其他路径也可写,更不能证明所有 Multica 部署的配置相同。 同样,我们以只读方式查询了当前进程与可见系统进程数量、PID 1 的 RSS 元数据,以及机器级内存统计;命令成功。这说明当前环境可以看到一定范围的进程元数据和内存统计。它不等于“能读取其他进程的内存内容”——后者通常需要额外的调试/附加权限,且受操作系统安全机制约束,本次没有尝试。 联网也应作相同区分:相关受管会话曾成功调用 Web 工具,因此能够完成必要的公开资料核验;这不是“Multica 为所有 runtime 开启了网页搜索”的产品承诺。Provider、部署与网络策略都会改变结果。尤其不要把 Codex Cloud 的默认设置外推到本地 daemon:Codex Cloud 的 agent phase 默认关闭互联网,可按环境开启并限制域名与 HTTP 方法;本地 Multica runtime 则运行在用户自己的机器和网络中。[OpenAI: Agent internet access](https://learn.chatgpt.com/docs/cloud/internet-access) ## 为什么“自动审批”值得被当成高风险配置 Multica 的价值是让任务无人值守地跑完,因此它不会把每一条本地命令都变成人工确认对话。官方安全模型说明:默认路径下,Codex 以 `danger-full-access` 运行,Claude Code 使用绕过权限的模式;Windows 上若显式启用 Codex 原生 sandbox,Multica 会保留该 opt-in。即使某个工具层的 sandbox 开启了,也不应把它误认作完整的隔离边界。[Multica Security model](https://multica.ai/docs/security-model) 换句话说,前半段通常是 Multica 的调度与授权,后半段是机器自己的安全模型: **图 2:危险操作从平台授权进入宿主机权限边界** ```mermaid flowchart LR S([开始:请求发起]) --> A[工作区与 Agent 触发授权] A --> B[runtime 认领任务] B --> C[任务 token 调用 Multica API] C --> D[本地 AI 工具执行命令] D --> E[OS 权限、凭据、网络与云 IAM] E --> F([结束:产生实际影响]) ``` 图中的分界点在本地工具执行之后:任务 token 约束的是 Multica API 身份,不能替代 OS、网络或云端权限控制。 若危险动作发生在最后一段,例如 `rm`、`git push --force`、`aws s3 rm`、向外部地址提交内容,不能期待“它来自 Multica”本身成为阻断理由。应该让这件事在更靠外的一层根本做不成。 ## 可操作的部署建议:把边界放到 daemon 外面 最轻量的做法是使用专用 OS 账号运行 daemon:只授予它所需仓库、最小范围的 deploy key 与云凭据,个人 SSH key、浏览器 profile 和生产管理员凭据不要放在该账号下。更强的选择是容器;要求更高时使用 VM。三种建议与 Multica 官方安全模型一致。[Multica Security model](https://multica.ai/docs/security-model) 我会再补四条工程上常用的控制: 1. 让云权限最小化。为 Agent 使用单用途、可轮换的短期凭据,不复用个人管理员凭据。 2. 把网络单独收口。默认不出网或只允许必要域名;若可配置方法,优先只放行 `GET`、`HEAD`、`OPTIONS`。OpenAI 也明确把 prompt injection、密钥外传、恶意依赖与许可证风险列为 Agent 出网的主要风险。[OpenAI: Agent internet access](https://learn.chatgpt.com/docs/cloud/internet-access) 3. 将发布、部署、删除生产资源放在独立审批链中。代码变更可以自动化,外部不可逆动作应由 CI、分支保护、云策略或人工批准门控制。 4. 审计实际生效的配置,而不是只看“我们开了 sandbox”。Multica 建议从 daemon log 与任务 `CODEX_HOME` 下的受管 `config.toml` 确认有效 sandbox 模式。[Multica Security model](https://multica.ai/docs/security-model) ## 最后的判断 Multica Runtime 擅长解决“谁在什么 runtime 上、为了哪条任务、以哪个任务身份执行”的问题;它不替你解决“这台机器上的这个用户应该被信任到什么程度”。 因此,正确的默认心智模型不是“危险命令会不会被 Multica 拦截”,而是:将每一个 Multica task 当成 daemon OS 用户在执行自动化工作。平台权限、任务级 token、独立 workdir 都很重要,但它们服务于协作隔离与降低误操作半径;真正的系统安全边界,应由专用身份、容器或 VM、最小权限凭据、网络策略和外部审批共同构成。 ## 参考资料 - [Multica Security model](https://multica.ai/docs/security-model) - [Multica Tasks](https://multica.ai/docs/tasks) - [Multica Agents](https://multica.ai/docs/agents) - [How Multica works](https://multica.ai/docs/how-multica-works) - [OpenAI — Agent internet access](https://learn.chatgpt.com/docs/cloud/internet-access) https://www.mkshell.com/posts/pi-agent-kernel-source-reading/ https://www.mkshell.com/posts/pi-agent-kernel-source-reading.md 2026-08-12 # pi 源码解读|pi 是一个怎样的 Agent Harness? “pi 能不能装几个插件,就变成 Hermes 或 OpenClaw 那样的个人 Agent?” 这个问题很容易把 pi 看错。它的终端界面、文件工具和多模型登录都很像一个完整 Coding Agent;而 Extension、Skills 和 Packages 又看起来什么都能接。于是直觉会变成:底座已经有了,剩下只是找插件。 源码和文档给出的边界恰好相反:pi 有意把核心做小,把工作流特有的行为推给扩展、Skill、Prompt Template 和 Package;它默认不内置 MCP、sub-agent、权限弹窗、Plan Mode、to-do 或后台 Bash。[pi 的设计说明写得很直白](https://pi.dev/docs/latest/usage#design-principles)。所以它可以长成一个 Agent OS,却不会因为安装几个扩展自动成为一个。 先说结论: > **pi 更适合被理解为“可编程的 Agent Kernel + Coding Agent 参考实现”。它已经负责模型调用、单 Agent loop、工具、上下文加载和会话树;跨渠道、长期记忆、任务调度、评测闭环、跨 Session 协作和 Graph Runtime 仍是上层 Control Plane 的责任。** 这不是在说 pi 功能少。相反,恰好是因为它把边界切得干净,才适合当一个需要长期演化的 Agent 系统的底座。 > **数据快照** > > - 最后核验:2026-08-12 > - 源码与文档:[`earendil-works/pi`](https://github.com/earendil-works/pi) 的 `main` 分支及 [pi Latest 文档](https://pi.dev/docs/latest/) > - 阅读边界:本文讨论 pi 公开的单进程运行时、CLI 和扩展接口;不比较不同 Agent 的任务成功率,也不把扩展示例等同于 pi 的默认能力 > - 名称说明:npm 包仍使用 `@earendil-works/*` 命名;下文“pi-agent-core”指该运行时包,不把它和整个 CLI 混为一谈 ## 先把产品外壳和运行时内核分开 把 pi 当成“又一个命令行 Coding Agent”并不完全错,只是观察位置太靠外。`pi-coding-agent` 是用户直接运行的程序;它把终端交互、文件工具、资源发现和 Session 接到一起。但当前 SDK 允许应用直接创建 `AgentSession`,用于嵌入自定义 UI、自动化管道、子 Agent 工具与程序化测试。[SDK 的定位](https://pi.dev/docs/latest/sdk#core-concepts)已经说明它不只为终端而存在。 可以先用下面这张图建立地图。 **图 1:从 pi-mono 包边界看,CLI 只是 Agent Runtime 的一个宿主;本文根据公开包名与 SDK 接口绘制** ```mermaid flowchart TB U["终端、Web 或自定义应用"] --> C["pi-coding-agent"] C --> S["AgentSession 与 ResourceLoader"] S --> A["pi-agent-core\n单 Agent loop"] A --> M["pi-ai\n模型与流式协议"] A --> T["内置或自定义 Tools"] S --> R["Extensions、Skills、Prompts、Context"] S --> P["SessionManager\nJSONL 会话树"] C --> UI["pi-tui / pi-web-ui"] ``` 图中最重要的是 `AgentSession`,不是 CLI。它持有 Agent、模型、消息、上下文压缩和事件订阅;调用方既可以 `prompt()` 等待一次任务结束,也能在流式过程中 `steer()` 或 `followUp()`。这意味着“界面”与“Agent 执行”已经分开,CLI 只是其中一个调用方。[`AgentSession` 的公开接口](https://pi.dev/docs/latest/sdk#agentsession)可以直接核对这些职责。 这也是本文用 Kernel 形容 pi 的原因:Kernel 不是完整操作系统,更不是最终产品;它给上层提供一组稳定而受约束的执行能力。这个类比只用于说明分层,不意味着 pi 提供了 Linux 那样的进程隔离、驱动模型或安全边界。 ## `pi-agent-core`:单 Agent loop 是内核,不是工作流引擎 Agent 最小闭环并不神秘:模型读到当前消息和工具描述,决定生成文本还是工具调用;工具结果写回消息;只要仍有工具调用,循环继续。 ```typescript // 分析性示例:展示职责,不是 pi 源码原样摘录 while (agent.hasNextStep()) { const reply = await model.stream(messages, tools); messages.push(reply); for (const call of reply.toolCalls) { messages.push(await tools.execute(call)); } } ``` pi 的 `AgentSession` 把这个循环包进更高一层的运行状态:消息历史、模型选择、thinking level、压缩、流式事件与中止。对于一个“读代码—修改—跑测试—继续修”的任务,这就是足够关键的底座。你不必先造一次模型流式协议、工具结果格式和取消语义,才能写上层行为。 但它的边界也在这里。单个 Agent loop 即使会反复调用工具,也仍然不是 Graph Runtime:它没有把 Planner、Coder、Reviewer、Judge 表达成一等节点,也没有为节点间共享状态、条件边、fan-out/fan-in、checkpoint 和恢复提供通用的图语义。 因此,下面两件事不能混为一谈: - 在一次 pi Session 内,用 Extension 在测试失败后追加一条“分析失败原因”的消息; - 运行一张持久的“规划—并行实现—验证—人工审批—回滚”执行图。 前者是**改造 loop**,pi 已经提供入口;后者是**编排多个 loop**,需要上层 Graph Controller,或交给专门的图框架。把 pi Agent 当作图里的一个 Node 通常很自然;把单 Agent loop 本身叫成完整 Graph Engine,就把“可以组合”误写成“已经提供”。 ## `pi-ai`:统一模型层是路由插口,不是路由策略 pi 的模型层承接不同供应商的鉴权、模型定义和流式调用。当前文档列出的内置或可配置路径覆盖订阅型 Provider、API Key Provider、Cloud Provider、`llama.cpp`,并支持通过 `models.json` 配置 Ollama、LM Studio、vLLM 等兼容端点。[Provider 文档](https://pi.dev/docs/latest/providers)给出的重点是“怎样接入”,不是替你决定“此刻该用哪个模型”。 如果需要非标准 API、企业 OAuth 或代理,Extension 可以调用 `pi.registerProvider()` 注册完整 Provider;自定义流式实现最终仍围绕 `pi-ai` 的 `streamSimple` 协议组织。[Custom Providers 文档](https://pi.dev/docs/latest/custom-provider)给出了这一层的接口和边界。 这让一个 Local First 系统可以把本地与远端模型接到同一个 Agent Runtime;但“根据任务难度、数据敏感度、预算和延迟选择 Qwen、Codex 或 Claude”仍是一条**路由策略**。它可以放在 Provider 之前、Extension 里,或放在上层 Control Plane,取决于策略是否要跨 Session、跨渠道或被审计。pi 提供的是插槽,不是默认的策略委员会。 ## `ResourceLoader` 和 Extension:可编程性真正从这里开始 如果只看 `pi.registerTool()`,容易把 Extension 理解成“给模型多塞几个函数”。实际接口宽得多。 `createAgentSession()` 通过 `ResourceLoader` 提供 Extension、Skill、Prompt Template、Theme 和 Context File;默认 Loader 会发现全局与项目内资源。[SDK 的加载说明](https://pi.dev/docs/latest/sdk#createagentsession)明确列出了这一点。对 Coding Agent 来说,这意味着同一个 loop 可以根据项目加载不同的工具、工作约束与交互层,而不用修改 Agent Core。 Extension 的工厂函数拿到 `ExtensionAPI` 后,至少可以订阅事件、注册工具、命令和快捷键;文档还提供 UI、动态工具、Provider 与 Session 相关能力。[Extension API 概览](https://pi.dev/docs/latest/extensions#extension-api)是最可靠的接口清单。 **表 1:pi 的扩展点与 Agent OS 上层能力的分工;pi 列基于 2026-08-12 的公开 SDK 与 Extension 文档** | 需求 | pi 已提供的插口 | 仍需要由系统定义的部分 | | ----------------- | ---------------------------------------- | --------------------------------------------- | | 增加工具 | `customTools` 或 `pi.registerTool()` | 工具目录、权限模型、凭据治理与审计 | | 改变单 Agent 行为 | 生命周期事件、消息注入、工具拦截 | 成功标准、重试预算、评测标准与终止协议 | | 接入本地/私有模型 | `models.json`、`registerProvider()` | 路由规则、成本/延迟策略与 fallback | | 加载项目知识 | Skill、Prompt、Context File、`AGENTS.md` | 知识更新、冲突处理、长期记忆的提取与遗忘 | | 改变终端体验 | 命令、快捷键、TUI UI API | Web/消息渠道、多用户身份和组织权限 | | 管理会话 | `SessionManager` 与 Session Runtime | 跨 Session 检索、用户画像、事实校验与保留策略 | 以“测试失败后自动进入反思”为例,Extension 能在工具事件后检查结果,并追加新的约束或工具;官方示例也展示了危险 Bash 先经过确认的拦截方式。[工具事件和拦截示例](https://pi.dev/docs/latest/extensions#tool-events)说明了这个方向。 但“能拦截”不是“已经安全”。一个真正的权限系统还要定义:谁能授权、授权多久、哪些对象可写、网络和文件系统的隔离在哪里做、日志能否证明实际副作用。pi 的扩展能力是实现这些策略的接缝,不应被误解为默认完成了安全治理。 ## Session 是可分叉的工作记录,不是长期记忆 pi 的会话不是一段简单的聊天文本。当前格式把 Session 存成 JSONL,条目以 `id` / `parentId` 形成树,因此可以在同一文件中切换分支;SDK 同时公开 `SessionManager.create()`、`open()`、`inMemory()`、`forkFrom()` 等入口。[Session File Format](https://pi.dev/docs/latest/session-format)和 [SDK 的会话管理章节](https://pi.dev/docs/latest/sdk#session-management)都能验证这点。 这对 Coding Agent 特别有用:一个错误的调试方向可以 fork,新的路径不用重新丢掉所有已有上下文;Context compaction 也能把长会话缩到模型还能继续工作的范围内。 不过这仍然不等于“长期记忆”。Session 保留的是一次执行的原始过程和分支关系;长期记忆至少还要回答另外几件事: - 什么信息值得从一段 Session 提炼为稳定事实; - 哪些事实属于项目、用户、团队或某个渠道; - 新证据与旧记忆冲突时,谁可以覆盖、怎样保留依据; - 检索到的记忆怎样标注来源、时效与置信度; - 删除、过期与隐私边界怎样处理。 把 Session 文件直接当知识库,短期很省事,长期会得到一个很大的“以前说过什么”的文件夹。它能帮助回放,却不能自动回答“现在还应不应该相信”。 ## 从 pi 向上长出 Agent OS,缺的是 Control Plane 把前面的模块放回最初的问题,答案就很清楚:pi 并不缺一个新的 Prompt,也不缺更多 Tool;它缺的是一个跨多个执行单元做决策的上层。 **图 2:把 pi 放进 Local First Agent OS 时,pi 是执行层,Control Plane 负责跨任务语义;本文解读** ```mermaid flowchart TB CH["TUI、Web、飞书、Telegram、Cron"] --> CP["Control Plane\n身份、任务、权限、预算"] CP --> G["Graph Controller\n路由、并行、重试、停止"] G --> PI1["pi AgentSession"] G --> PI2["pi AgentSession"] PI1 --> TOOLS["Tools / MCP / Sandbox"] PI2 --> TOOLS CP --> MEM["Memory\n提炼、检索、遗忘"] CP --> EVAL["Judge / Eval / Observability"] MEM --> PI1 EVAL --> G ``` 这张图没有试图把所有东西都塞进 Extension。原因很简单:一旦 Scheduler、渠道身份、跨任务 Memory、评测历史和 Graph 状态都只存在于某个终端 Session 的扩展里,它们就很难独立观察、恢复和治理。 一个较稳妥的分层是: - **pi 负责执行**:模型交互、工具调用、单任务上下文、项目资源发现和可扩展 CLI/SDK; - **Graph Controller 负责编排**:创建哪些 Session、哪些可以并行、结果怎样汇总、失败回到哪里、何时要求人工确认; - **Memory Layer 负责事实**:从记录中提炼可复查的记忆,而不是把全部消息再次塞回 Prompt; - **Eval 与 Policy 负责停止和边界**:测试通过不一定代表任务完成,模型说“完成”也不应替代权限和验收; - **Channel Layer 负责产品入口**:把消息、定时任务、Web UI 或设备事件规范化成可以提交给 Control Plane 的任务。 这也是为什么“pi + 插件 = Hermes”不够准确。插件当然能实现很多单点能力,甚至可以做 sub-agent、MCP 或权限门;而 [pi 的 Package 机制](https://pi.dev/docs/latest/packages)也确实能把 Extension、Skill、Prompt 和 Theme 打包分发。但一个 Agent OS 的困难不在于把能力装进去,而在于这些能力怎样共享身份、状态、权限、预算和完成证据。 ## 源码阅读顺序:先追执行边界,再追产品能力 如果目标是做二次开发,建议不要从 CLI 的命令列表开始。更高效的顺序是: 1. 先读 `pi-agent-core` 与 `AgentSession`:确认一次模型回复、工具调用、消息追加、取消与压缩怎样构成单任务 loop。 2. 再读 `ResourceLoader` 和 `DefaultResourceLoader`:确认项目的 Extension、Skill、Prompt 与 `AGENTS.md` 在什么范围、什么顺序加载。 3. 然后读 Extension 事件与 `registerTool()`:找清可插入的生命周期位置,以及工具、Provider、UI 与 Session 的实际能力。 4. 接着读 `SessionManager` 的 JSONL 树与 Session Runtime:区分“原始会话持久化”“分支”和“长期 Memory”。 5. 最后才设计 Graph Controller、Memory、Policy 和 Eval:这些不应由某个局部 Hook 的方便程度反推架构。 这个顺序的好处是,每一步都先确定 pi 已经承诺的接口,再决定自己的系统要在哪一层加能力。否则很容易先写出一个“自进化 Extension”,最后才发现跨 Session 的状态、失败恢复和评测数据都无处安放。 ## 总结:pi 不是成品 OS,但正因如此值得作为底座 pi 的价值不在于替你打包了多少“聪明功能”,而在于它把最难复用、最容易被 CLI 外壳遮住的部分做成了可调用的运行时:`AgentSession`、工具、模型层、资源加载、Extension 和树状 Session。 它能支持 Loop engineering:你可以改变单 Agent 在何时观察、验证、重试或停止。它也能成为 Graph engineering 的执行节点:上层把 Planner、Worker、Judge 和人工审批组织起来,再让每个节点使用 pi 的 loop 和工具。但这两句话都不等于 pi 已经带着完整的 Graph、Memory、Channel、Scheduler 和治理系统。 所以,若目标只是尽快得到一个个人助手,选择已经把渠道、任务和记忆打包好的产品往往更省力;若目标是构建一个可本地部署、可评测、可演化的 Agent OS,pi 的克制反而是一种优势。下一篇可以继续沿着这条边界,具体追 `ResourceLoader` 与 Extension 如何把“外部能力”接进一次 Agent Session。 ## 参考资料 - [Pi:SDK](https://pi.dev/docs/latest/sdk) - [Pi:Extensions](https://pi.dev/docs/latest/extensions) - [Pi:Session File Format](https://pi.dev/docs/latest/session-format) - [Pi:Providers](https://pi.dev/docs/latest/providers) - [Pi:Custom Providers](https://pi.dev/docs/latest/custom-provider) - [Pi:Packages](https://pi.dev/docs/latest/packages) - [Pi:Design Principles](https://pi.dev/docs/latest/usage#design-principles) - [pi-mono 源码仓库](https://github.com/earendil-works/pi) https://www.mkshell.com/posts/large-codebase-context-scope/ https://www.mkshell.com/posts/large-codebase-context-scope.md 2026-08-06 2026-08-10 # Cookbook解读|大型代码库里,先别让 Agent 读完整个仓库 让 Agent 修复登录失败,它却先读了 `dist/`、十年前的 `legacy/`,又翻到了另一个团队的前端约定。等它真的找到 `AuthService`,上下文里已经塞满了和这次故障无关的东西。 这不是模型“不够聪明”的一个好例子。更像是让一个刚入职的同事拿着整层楼的门禁卡查线上问题:门都能进,反而不知道应该先敲哪一扇。 这篇文章带读的原始材料不是 OpenAI Cookbook,而是 Anthropic 的 [Claude Code《Set up Claude Code in a monorepo or large codebase》](https://code.claude.com/docs/en/large-codebases)。它被放进本博客的「Cookbook 解读」系列,是因为它同样提供了一套可以照着落地的工程做法。 先说结论:**大仓库里最有用的不是“把更多代码塞进 Agent”,而是让任务决定它从哪里开始、哪些规则随身带着、哪些文件根本不该打开;当任务真的跨出边界时,再显式扩大范围并把关键决定写下来。** > **数据快照** > > - 最后核验:2026-08-10 > - 原始材料:[Claude Code:Set up Claude Code in a monorepo or large codebase](https://code.claude.com/docs/en/large-codebases) > - 文章边界:本文解释该指南公开的配置与工作流,不比较模型能力、上下文窗口大小或不同产品的质量与成本。 ## 原文先解决什么问题:不是仓库太大,而是注意力被用错了 假设一个电商 monorepo 有订单、支付、优惠券和后台管理四个包。登录出错只发生在 API,可 Agent 一上来从根目录扫完整个仓库。它当然可能最终找到答案,但每一次无关读取都会让真正的证据变得更难辨认。 **原文引用|指南对大仓库问题的定义** > unrelated to the task, costing tokens and degrading Claude’s performance. 原文说明的是:随着代码库长大,为小项目设计的默认行为会让无关指令和文件读取填满上下文,从而增加 token 成本、降低表现。[这段原文](https://code.claude.com/docs/en/large-codebases)能证明官方把“任务无关的读取”当作问题;它不能证明某一种配置会自动理解业务,也没有给出不同设置的质量排名。 所以这份指南的核心不是一条“让 Agent 先探索”的抽象口号,而是把探索拆成几道能实际配置的边界。它们可以叠加,也不要求每个仓库一次性全上。 **原文引用|各项设置之间的关系** > They layer rather than replace each other. 这句话解释了为什么不能只加一份巨型 `CLAUDE.md` 就宣布完成 Context Engineering。启动目录、目录规则、读取权限、语言服务、worktree 和 Skill 分别管不同的事情;它们是工具箱,不是一道必答题。[原文的设置总览](https://code.claude.com/docs/en/large-codebases#settings-on-this-page)列出了每个工具试图解决的具体问题。 ## 第一层:先决定从哪扇门进,而不是从大厅喊“我来了” 在大型仓库里,`claude` 从哪里启动,决定了默认能访问哪些文件、启动时会带上哪些 `CLAUDE.md`,以及哪一份项目设置生效。[原文的启动位置对照](https://code.claude.com/docs/en/large-codebases#choose-where-to-start-claude)给出了两个很不一样的入口: - 从仓库根目录启动,适合要同时改多个 package 的任务; - 从 `packages/api/` 启动,适合只影响 API 的任务,默认视野先收在这个子树中。 这不是“目录越深越好”。如果登录改动还要更新 `packages/shared/` 的 Session 类型,把 Agent 困在 `packages/api/` 只会让它在发现依赖后再次申请访问。正确的判断是:**先按已知影响面给一个默认范围,新的证据出现后再扩大。** ### 原文的做法:把规则放在代码附近 **原文方案引用|官方示例中的分层目录结构(节选)** ```text monorepo/ CLAUDE.md packages/ api/ CLAUDE.md .claude/skills/ web/ CLAUDE.md ``` 上面的结构来自[官方示例 monorepo](https://code.claude.com/docs/en/large-codebases#the-example-monorepo)。根目录保存全局布局、提交约定等通用信息;`api/` 目录保存 API 的测试、迁移和框架约束;读取更深目录时,再按需加载那里自己的说明。 把它想成医院的导诊:大厅只告诉你楼层和总规则;真的走到放射科,才需要知道那里的预约流程。把所有科室的细节印进同一张大厅海报,只会让病人和导诊护士一起看花眼。 **本文解读**:`CLAUDE.md` 更像“这一片代码的使用说明”,不该是百科全书。根目录放所有人、所有任务都需要的约定;某个 package 的测试命令和禁忌,就留给这个 package 的 owner 维护。原文也提醒,短期只想专注一个目录时,优先从该目录启动,而不是频繁修改 `claudeMdExcludes` 这类长期排除策略。 ## 第二层:挡住不该读的文件,再让工具找到该读的符号 规则只解决“该带什么说明”;Agent 真正消耗上下文的大头,通常还是文件内容。 ### 生成物不是证据,除非任务正是在查生成物 `node_modules/`、构建产物、提交进仓库的 vendor SDK,常常会在搜索结果中显得很热闹,却很少能解释业务故障。官方指南区分了两种情况:已在 `.gitignore` 中的常见目录通常不会进入内容搜索;对已提交的生成代码或 vendor 依赖,则可以用 `permissions.deny` 的 `Read` 规则阻止打开。[原文关于受限读取的说明](https://code.claude.com/docs/en/large-codebases#block-reads-of-generated-and-vendored-code)还特别限定:这类规则并不会过滤递归搜索的输出,也不能管住任意子进程。 **原文方案引用|阻止读取构建产物与 vendor 代码(节选)** ```json { "permissions": { "deny": ["Read(./**/dist/**)", "Read(./vendor/**)"] } } ``` 这段配置来自[官方的 `permissions.deny` 示例](https://code.claude.com/docs/en/large-codebases#block-reads-of-generated-and-vendored-code)。它证明的是“打开文件时可以设定边界”,不代表这些目录永远无用;排查打包产物或第三方补丁时,反而可能需要临时把它们放回视野。 ### 不要让 `grep` 承担语言服务的工作 现在回到“登录失败”。如果问题是“`Session` 类型在哪定义、谁在调用它”,全仓搜索关键词会得到一大堆同名命中。官方建议接入 code intelligence plugin,让语言服务器回答定义、引用和类型错误,把文件扫描换成符号级定位。[原文对 code intelligence 的说明](https://code.claude.com/docs/en/large-codebases#reduce-file-reads-with-code-intelligence)把它和排除规则放在同一节,原因很直白:前者移走噪声,后者在剩下的代码里更快找路。 不过,LSP 不是业务分析师。它能画出 `RefundCreated` 的调用关系,却不能告诉你退款后是否必须返还优惠券、优惠券是否有冻结期、幂等键放在哪。这些仍然要靠领域文档、测试和任务的验收条件。**缩小检索范围,不等于省掉业务判断。** ## 第三层:跨包时扩大世界,但别丢掉已经做出的决定 设想登录问题最后被定位到共享类型:`packages/api/` 需要修改接口,`packages/shared/` 需要改类型,`packages/web/` 还要改一个调用点。这时“只看 API”已经不成立。 **图 1:本文解读|登录任务的范围如何随着证据扩大;图中示例是分析性场景,不是官方实测流程** ```mermaid flowchart LR S([开始:收到登录失败任务]) --> A[从 API 目录启动] A --> B[加载根目录与 API 规则] B --> C[跳过生成物与 vendor] C --> D[用语言服务定位 Session 引用] D --> E{发现 shared 类型?} E -- 否 --> F[修改 API 并运行对应测试] E -- 是 --> G[显式纳入 shared 与调用点] G --> H[写下变更计划与验证项] H --> I[同一任务中完成一致改动] F --> Z([结束:交付可验证改动]) I --> Z ``` 图里最重要的转折在 `shared`:它不是“越界失败”,而是要扩大范围的证据。官方指南给出两类工具:`additionalDirectories` 或 `--add-dir` 处理当前 session 对兄弟 package/其他仓库的访问;`worktree.sparsePaths` 让 worktree 只检出任务需要的目录。[原文的跨目录访问说明](https://code.claude.com/docs/en/large-codebases#grant-access-across-packages-or-repositories)与[稀疏 worktree 说明](https://code.claude.com/docs/en/large-codebases#check-out-only-the-directories-you-need)分别覆盖这两层边界。 **原文引用|跨 package 变更的一条建议** > Save the plan to a file before editing. 原文建议在编辑前把计划写入仓库,因为较长 session 可能压缩上下文,而文件中的计划能留下已确认的决定。[原文的跨包 scope 与 plan 建议](https://code.claude.com/docs/en/large-codebases#scope-and-plan-changes-that-span-packages)能证明“计划文件可跨压缩保留”;它不代表每一个两行改动都必须新建一份计划。 这一点也解释了多 Agent 的边界。只读探索适合独立出去:把大量目录遍历、搜索命中留在子任务中,主任务只拿回文件位置、证据和未解项。但“要不要改共享类型”“哪个回归测试算完成”必须由同一个变更保持一致;把这些决定拆成几段互不相通的对话,才是真的让 Agent 迷路。 ## 把原文的工具箱换成一次可执行的判断 下面这张表不是官方的质量对比,而是基于上述指南整理的选择入口。 **表 1:本文解读|按任务信号选择范围控制工具;结论基于 2026-08-10 核验的官方指南,不是性能测量** | 看到的信号 | 先做什么 | 不要误解成 | | --------------------------------------- | ------------------------------------------------------------- | --------------------------------------- | | 任务只改一个 package | 从该子目录启动,加载根目录与本地规则 | 永远不能读兄弟 package | | 根规则越来越长、越来越像项目百科 | 将局部测试/框架规则放入目录级 `CLAUDE.md` 或 path-scoped rule | 每个目录都必须复制一份总规则 | | 搜索不断命中构建产物、生成代码或 vendor | 用 `.gitignore` 与 `Read` deny 缩小读取面 | 从此不可能调试这些目录 | | 问题是“定义或调用者在哪里” | 用 code intelligence 查符号 | LSP 已经理解业务语义 | | 任务涉及共享类型和多个调用点 | 显式增加目录,先记录计划再改 | 把每个 package 交给互不共享决定的 Agent | 如果团队反复在同一目录做同类操作,原文还建议把局部流程做成按需加载的 Skill;如果分散的 `CLAUDE.md` 已经难以治理,再考虑由平台团队维护 plugin 或 MCP。[目录级 Skill](https://code.claude.com/docs/en/large-codebases#add-per-directory-skills)和[集中治理建议](https://code.claude.com/docs/en/large-codebases#centralize-conventions-when-layering-stops-scaling)回答的是“规则如何长期维护”,不是一次登录故障的即时修复方案。 ## 总结:先把问题缩到可解释,再把范围扩大到足够完成 大型代码库并不要求 Agent 永远待在一个小盒子里。更好的策略是:起步时只带与任务有关的规则和文件;用符号工具减少盲目翻找;当共享类型、调用点或测试给出新证据时,再把世界扩大一圈,并把已经做出的决定写进计划。 下次让 Agent 改一项跨包功能时,可以先问四个问题:任务最初影响哪个目录?哪些说明必须加载?哪些文件只会制造噪声?一旦发现跨包依赖,哪些决定必须和计划一起保留? 能回答这四个问题,Agent 就不必先读完整个仓库,才有机会把真正需要读的那部分读明白。 ## 参考资料 - [Claude Code:Set up Claude Code in a monorepo or large codebase](https://code.claude.com/docs/en/large-codebases),2026-08-10 核验 https://www.mkshell.com/posts/pi-harness-vs-agent-os/ https://www.mkshell.com/posts/pi-harness-vs-agent-os.md 2026-08-06 # Pi 源码解读|Agent Harness 和 Agent OS,到底差了什么? “Pi 能不能装几个插件,直接变成 Hermes 或 OpenClaw?” 这个问题很容易被功能表带偏。四个项目都能接模型、调工具,也都能扩展;如果只看这些方框,Pi 和它们像是少装了几块积木。 但顺着 Pi 的源码往下看,结论要更克制一些:**Pi 是一个把 Agent 循环、会话与扩展接口做得很干净的 Harness;Hermes、OpenClaw 和 nanobot 则把消息入口、持续运行、记忆、调度与治理一起交付成了 Agent OS。** 前者可以长成后者,但不是把 Extension 数量加到某个阈值就会自动发生。缺的不是一个天气工具,而是一组必须共同运行、共同审计的控制面。 ## 先说结论 Pi 适合作为可控的 Agent 执行底座;若目标是“开箱即用的个人助手”,还需要另行拥有或选择提供渠道、调度、身份、权限、记忆和运维的 Agent OS。本文据此区分源码已经提供的能力与必须由上层补齐的责任。 > **数据快照** > > - 最后核验:2026-08-06 > - Pi 源码快照:[`bde81c8`](https://github.com/earendil-works/pi/tree/bde81c84405514c8b0f57c34405c152fb129c0ce)(`main` 分支当日提交) > - 主要对象:Pi Agent Harness、Hermes Agent、OpenClaw、nanobot > - 讨论边界:本文比较它们的运行时职责,不比较模型能力、社区规模或某次任务的成功率 > - 证据限制:Pi 部分以固定源码快照为准;其余三个项目的“开箱能力”来自当日官方 README / 文档,不能推出它们在每种部署配置下都有相同表现 ## 先把“Agent”拆成两层 用人话说,Harness 是发动机和驾驶控制:给它任务、模型、工具与状态,它能把一轮又一轮工作跑完。Agent OS 则还要负责让这台发动机长期待命:消息从哪里进来、该由哪个会话处理、什么时候唤醒、用谁的身份访问外部系统、出了问题如何停下与追溯。 **表 1:四个项目的公开职责边界;状态以 2026-08-06 的官方资料为准** | 维度 | Pi | Hermes Agent | OpenClaw | nanobot | | -------------------------- | ----------------------------------------------------------- | ---------------------------------- | ----------------------------------------- | -------------------------------------------------- | | 核心定位 | 可嵌入的 Agent runtime 与 coding CLI | 自我改进的个人 Agent | 运行在设备上的个人助手 | 自托管个人 Agent runtime | | 单轮 Agent loop / 工具调用 | 有 | 有 | 有 | 有 | | 扩展入口 | TypeScript Extension、工具、事件与 TUI | 工具、Skills、插件与多种后端 | Tools、Skills、Plugins | Python SDK、MCP、工具与子 Agent | | 消息与网关 | 主仓库不提供个人助手网关;另有 `pi-chat` 项目面向聊天自动化 | Gateway 连接多种聊天渠道 | Gateway 统一会话、事件与渠道 | WebUI、聊天应用与长期运行 gateway | | 持续任务与长期记忆 | 可由扩展自行实现 | 内置 cron、跨会话记忆与 skill 演化 | Gateway、渠道、工具与设备节点构成持续服务 | goals、memory、cron 与 automation 已在运行时中提供 | | 默认安全边界 | 不内置文件、进程、网络或凭据权限系统 | 提供命令审批、配对和隔离文档 | 有配对、sandbox 与暴露运行手册 | 需按其配置与部署文档审查 | Pi 的项目 README 明确把自己拆为 `pi-agent-core`、`pi-ai`、`pi-coding-agent` 和 TUI:运行循环、模型适配、交互式编码界面各自独立。[Pi README](https://github.com/earendil-works/pi/tree/bde81c84405514c8b0f57c34405c152fb129c0ce) 对这个边界说得相当直白。相对地,[Hermes](https://github.com/NousResearch/hermes-agent)、[OpenClaw](https://github.com/openclaw/openclaw) 和 [nanobot](https://github.com/HKUDS/nanobot) 的公开入口都把聊天渠道、持续服务或自动化放进产品定义。 这不是“谁更高级”的排序。想在本地终端里精确控制一个 Coding Agent,较小的 Harness 往往更舒服;想让同一个 Agent 在 Telegram、定时任务和多天记忆之间持续工作,OS 的默认层反而省掉大量不显眼的工程。 ## Pi 的最小闭环:`Agent` 只负责把一轮工作跑完 Pi 的 `packages/agent/src/agent.ts` 是理解边界最短的入口。`Agent` 保存当前 transcript、工具集和模型状态;收到 prompt 后,它把消息交给 `runAgentLoop()`,并在循环中收集模型消息、工具执行和生命周期事件。[`Agent.prompt()` 与 `runAgentLoop()`](https://github.com/earendil-works/pi/blob/bde81c84405514c8b0f57c34405c152fb129c0ce/packages/agent/src/agent.ts#L324-L455) 的关系大致可以浓缩成下面这样;为说明结构,省略了类型、流式更新与错误处理。 ```ts class Agent { async prompt(input: string) { const messages = this.normalizePromptInput(input); await runAgentLoop( messages, this.createContextSnapshot(), this.createLoopConfig(), (event) => this.processEvents(event), signal, this.streamFunction ); } } ``` 这个对象的职责很明确:模型是否继续、工具何时执行、结果怎样进入下一轮上下文,都是 loop 的内部问题。源码还把 `beforeToolCall`、`afterToolCall`、`shouldStopAfterTurn` 和 `prepareNextTurn` 放进 loop 配置中。[对应的配置装配代码](https://github.com/earendil-works/pi/blob/bde81c84405514c8b0f57c34405c152fb129c0ce/packages/agent/src/agent.ts#L416-L454) 证明了它为外部策略留出了钩子;它**不能**证明 Pi 已经替你定义了“什么任务应该每天九点运行”或“陌生 Telegram 用户能否创建会话”。 **图 1:Pi `Agent` 的单次执行闭环;依据 2026-08-06 的源码快照** ```mermaid flowchart TD S([开始:输入 prompt]) --> A[Agent 生成消息与上下文快照] A --> B[runAgentLoop 请求模型] B --> C{模型请求工具?} C -- 是 --> D[执行或拦截工具调用] D --> E[工具结果写入 transcript] E --> B C -- 否 --> F[发出 turn_end / agent_end 事件] F --> G([结束:本次 Agent run 空闲]) H[外部 hook] -. 可改工具、停止或下一轮 .-> D H -. 可改工具、停止或下一轮 .-> B ``` 读图时最值得注意的是终点:它是“这次 run 空闲”,不是“服务继续待命”。Pi 的 loop 已经是 Agent 最难替换的一部分,但长期运行的入口和调度不在这段 loop 里。 ## `AgentSession` 补的是会话,不是控制平面 如果只看 `Agent`,Pi 像一个很干净的循环库;`pi-coding-agent` 再用 `AgentSession` 把它变成可交互的产品。这个类被 interactive、print、RPC 等运行模式共享,负责自动保存会话、模型与 thinking level、压缩、分支和 bash 执行。[文件头的职责说明](https://github.com/earendil-works/pi/blob/bde81c84405514c8b0f57c34405c152fb129c0ce/packages/coding-agent/src/core/agent-session.ts#L1-L13) 与 [它对 `Agent`、session manager、extension runner 的依赖](https://github.com/earendil-works/pi/blob/bde81c84405514c8b0f57c34405c152fb129c0ce/packages/coding-agent/src/core/agent-session.ts#L16-L109) 共同说明了这层的任务。 这给 Pi 带来两个很实际的能力:同一段对话可以恢复、压缩和分叉;不同交互界面可以复用同一会话运行时。它仍然没有替上层回答三个 OS 问题: - 一条外部消息怎样路由到正确的 `AgentSession`; - 没有前台终端时,谁负责唤醒、超时、重试和投递结果; - 这次会话的 shell、网络和凭据到底代表哪个用户、遵循哪套审批规则。 把 Session 当成 Memory 也会造成误判。Session 首先是可恢复的执行记录和当前上下文;长期偏好、跨任务知识、可检索事实、失效策略分别需要不同的写入条件、作用域和淘汰策略。Hermes 所说的跨会话记忆与 Skills 演化,或 nanobot 所说的长期记忆,讨论的是这个更上层的问题,而不仅是“把 JSONL 留在磁盘上”。 ## Extension 给了积木,但没有替你画出城市规划 Pi 的 Extension API 比“插件能加一个工具”宽得多。扩展可以注册模型可调用的工具、订阅生命周期事件、拦截或修改工具调用、注入上下文、定制压缩、添加命令和 TUI,并用 `appendEntry()` 保存自定义状态。[官方 Extension 文档](https://pi.dev/docs/latest/extensions) 还明确说明,扩展拥有宿主进程的完整系统权限。 这意味着对话中的判断有一半是对的:可以在 Pi 上做出 memory tool、浏览器工具、定时任务入口,甚至做一个分层的审批界面。比如,下面这种拦截确实可以把危险 shell 命令从“模型想调用”变成“用户确认后才调用”。 ```ts pi.on('tool_call', async (event, ctx) => { if (event.toolName === 'bash' && event.input.command?.includes('rm -rf')) { const ok = await ctx.ui.confirm('危险命令', '是否允许执行?'); if (!ok) return { block: true, reason: 'Blocked by user' }; } }); ``` 但它也恰好解释了为什么“装插件就开箱即用”不成立。上例只是一个字符串规则:它没有识别软链接、间接执行、远程 shell、凭据外泄,也没有给后台任务提供可复核的身份。Pi README 对此的立场很明确:它**不含**限制文件系统、进程、网络或凭据访问的内置权限系统,默认使用启动进程的权限;更强边界需要容器化或 sandbox。[权限与容器化说明](https://github.com/earendil-works/pi/blob/bde81c84405514c8b0f57c34405c152fb129c0ce/README.md#L204-L211) 不是缺失的文档,而是项目有意保留给宿主的责任。 ## 从 Pi 长成 Agent OS,真正要补的是五条跨层链路 把工具、记忆、渠道这些名词列出来很容易;困难在于它们必须和同一份身份、状态和审计记录连起来。下面的图不是 Pi 当前架构,而是基于其公开扩展点作出的**实现建议**。 **图 2:在 Pi 之上增加 Agent OS 控制面的建议分层;这是本文的工程推断,不是 Pi 官方路线图** ```mermaid flowchart TB A[渠道:TUI、Web、Telegram、飞书] --> B[Gateway:鉴权、限流、会话路由] C[Scheduler:cron、事件、重试] --> B B --> D[Control Plane:任务、身份、审批、审计] D --> E[Pi AgentSession] E --> F[Pi Agent loop] F --> G[工具与 MCP] G --> H[执行边界:沙箱、网络、凭据代理] I[Memory:短期、长期、知识、经验] <--> D I <--> E J[Eval:验收、失败分类、回归] --> D J --> E ``` 其中最容易被低估的有五条: 1. **渠道到会话。** 收到消息不是 `session.prompt(text)` 就结束了。还要确定发送者、租户、会话 key、并发策略和结果回投位置。 2. **任务到调度。** cron 触发后需要去重、超时、重试、失败通知和停止开关,否则“每天总结”很快变成每天制造一条无人处理的失败日志。 3. **记忆到权限。** 一条长期记忆应属于用户、项目还是共享空间?能否被低权限渠道读到?何时撤回?这些都不能交给向量检索的相似度独自决定。 4. **工具到执行边界。** 模型提出一个 tool call,只是意图;真正的权限检查应在网络、文件、进程和凭据即将产生副作用的位置再次发生。 5. **结果到评测。** Loop Engineering 不是“失败了再塞一句反思 prompt”。至少要能记录目标、成功信号、失败类别与重试预算,才知道循环是在变好还是只是在多花 token。 所以 Pi 很适合承担图中的 `AgentSession + Agent loop`:核心循环小、生命周期清楚、工具和 UI 可替换。Graph Engineering 也不是做不到,只是图中的 Planner、Coder、Reviewer、Judge 不能只靠一次 `Agent.prompt()` 自然长出来;你还要在 Control Plane 定义节点状态、边条件、并发、失败回收和最终验收。Pi 可以是图节点的执行器,却不会自动成为图调度器。 ## 怎样选择:不要先问“能不能”,先问哪一层该由谁拥有 如果目标是可改造的 Coding Agent、特殊工具链,或一套需要自己掌握调度语义的 local-first runtime,Pi 是很好的起点。你可以把最小闭环跑通,再逐层增加有明确需求的渠道、Memory、审批和评测。 如果目标是今天就要通过聊天渠道处理持续任务,优先评估 Hermes、OpenClaw 或 nanobot 是否已经覆盖你的部署、身份与安全要求。它们省下的不是几段 plugin 代码,而是网关、生命周期和默认操作路径;当然,也会把更多运行时行为带进你的系统。 最需要避免的路线是:先把 Pi 接到所有渠道和高权限工具,再补权限、持久化和观测。那会得到一个很会做事、但没人说得清它什么时候醒来、拿谁的权限、又留下了什么的 Agent。 ## 总结:Pi 缺的不是能力清单,而是 OS 的责任边界 Pi 已经提供了 Agent 的骨架:模型调用、工具循环、会话、压缩、扩展与交互界面。源码中的 `Agent` 和 `AgentSession` 也解释了它为什么特别适合做 Loop Engineering 的执行底座:每一轮的状态、生命周期和工具调用都有明确入口。 但 Hermes、OpenClaw、nanobot 所在的层次不只是“更多 tools”。它们把渠道、Gateway、定时任务、长时记忆、身份、安全和持续运维组合成一个长期服务。Pi 可以向上生长,前提是你愿意明确拥有这些控制面的设计与维护。 下一篇会继续沿着 Pi 的源码看 Extension:一个扩展究竟能在什么时机改变上下文、拦截工具和保存状态;以及为什么这既是 Pi 最强的可塑性,也是最需要先设计安全边界的地方。 ## 参考资料 - [Pi 仓库,源码快照 `bde81c8`](https://github.com/earendil-works/pi/tree/bde81c84405514c8b0f57c34405c152fb129c0ce) - [Pi:`Agent` 的运行循环入口](https://github.com/earendil-works/pi/blob/bde81c84405514c8b0f57c34405c152fb129c0ce/packages/agent/src/agent.ts#L324-L455) - [Pi:`AgentSession` 的共享运行时职责](https://github.com/earendil-works/pi/blob/bde81c84405514c8b0f57c34405c152fb129c0ce/packages/coding-agent/src/core/agent-session.ts#L1-L109) - [Pi Extension 文档](https://pi.dev/docs/latest/extensions) - [Hermes Agent 官方仓库](https://github.com/NousResearch/hermes-agent) - [OpenClaw 官方仓库](https://github.com/openclaw/openclaw) - [nanobot 官方仓库](https://github.com/HKUDS/nanobot) https://www.mkshell.com/posts/plan-goal-spec-agent-contracts/ https://www.mkshell.com/posts/plan-goal-spec-agent-contracts.md 2026-08-05 # Cookbook解读|Plan、Goal、Spec 为什么不能互相替代 给 Coding Agent 一个 Spec,让它先做 Plan,再开一个 Goal,最后还要准备 Skill 和 `AGENTS.md`——是不是把同一件事换了五种写法? 这个疑问很合理。它们都以自然语言出现,也都可能写着“必须运行测试”“不要改公共 API”。如果只看文件内容,确实像一叠互相抄作业的 Markdown。 问题在于,我们把不同层级的控制对象放进了同一个抽屉。OpenAI Cookbook 的 [Using PLANS.md for multi-hour problem solving](https://developers.openai.com/cookbook/articles/codex_exec_plans) 和 [Using Goals in Codex](https://developers.openai.com/cookbook/examples/codex/using_goals_in_codex),恰好从“路径”和“终点”两侧把这个抽屉拆开;GitHub Spec Kit 又补上了更上游的需求契约。 先说结论: > Spec 定义“要构建什么,以及不能破坏什么”;Goal 定义“当前任务凭什么可以停止”;Plan 记录“从现状走到目标的路径”;Skill 封装“同类任务以后怎样重复做”;`AGENTS.md` 或 `CLAUDE.md` 保存“在这个环境里长期遵守什么”。Validation 与 Eval 则提供证据,防止前面几份文档最后只剩下措辞正确。 它们会引用彼此,却不能互相替代。一个写得很细的 Plan 仍可能在实现错误需求;一个验收条件清晰的 Goal 也不会自动知道数据库迁移应该先做哪一步。 > **数据快照** > > - 最后核验:2026-08-04 > - 主要对象:OpenAI Cookbook 的 ExecPlan 与 Codex Goal、OpenAI Codex 官方文档、Anthropic Claude Code 官方文档、GitHub Spec Kit 官方文档 > - 产品门槛:Codex Goals 从 `0.128.0` 起可用;Claude Code `/goal` 文档要求 `2.1.139` 或更高版本 > - 证据边界:本文比较公开的控制机制,不提供不同工作流的成功率、成本或质量排名 ## 第一个坑:Plan Mode、Plan 和 `PLANS.md` 不是一件事 讨论 Plan 之前,先要把三个同名对象分开。 ### Plan Mode 是交互边界 Claude Code 的 [Plan Mode](https://code.claude.com/docs/en/permission-modes#analyze-before-you-edit-with-plan-mode) 允许 Claude 读取文件、运行探索命令并提出修改方案,但不编辑源码;用户批准后才切换到实施阶段。Codex 的官方命令文档也把 [`/plan`](https://learn.chatgpt.com/docs/developer-commands#built-in-slash-commands) 描述为“在实现开始前提出执行计划”。 所以 Plan Mode 首先是一种**交互与权限状态**:先调查和设计,把修改推迟到审阅之后。它可以产出一份 Plan,但“现在处于 Plan Mode”和“仓库里存在一份长期计划文档”不是同一句话。 ### Plan 是当前任务的路径 普通 Plan 回答的是:基于现在看到的代码,准备改哪些模块,按什么顺序推进,怎样验证,哪里有风险。 它可以很短: ```text 1. 找出旧 OAuth 回调与 Session 写入位置 2. 增加 Provider Adapter,保留旧登录路径 3. 迁移 GitHub 回调并补集成测试 4. 运行认证测试与回归测试 ``` 路径依赖调查结果,因此允许改变。第二步发现用户表无法表达多个身份源,Plan 就应该更新,而不是为了证明自己“计划得很准”继续撞墙。 ### `PLANS.md` 定义长期计划应该怎样写 OpenAI Cookbook 的 ExecPlan 又多了一层。`PLANS.md` 不是某个功能的具体计划,而是对**计划文档本身的仓库级约定**:什么时候需要 ExecPlan,它必须包含什么,执行过程中怎样维护。 Cookbook 要求 ExecPlan 自包含、面向不了解仓库的新手,并持续更新 `Progress`、`Surprises & Discoveries`、`Decision Log` 与 `Outcomes & Retrospective`。它既是设计文档,也是多小时任务的状态载体。文章甚至要求只拿到当前工作树和这一份 ExecPlan,也能继续推进。 这是一种适合长任务的 Cookbook 工作法,不是所有 Codex Plan 的强制格式。原文还带有当时的模型推荐;本文只采用它的计划模式,不把那条推荐当成 2026-08-04 的模型选择结论。 到这里可以先得到一个小结:Plan Mode 决定“现在先不修改”,Plan 决定“这次准备怎么改”,`PLANS.md` 决定“本仓库的长计划应该怎样写和维护”。三者相关,但作用域完全不同。 ## Spec 与 Plan 的分界:先决定做对的东西,再决定怎样做 GitHub Spec Kit 的官方流程是 [Spec → Plan → Tasks → Implement](https://github.github.com/spec-kit/)。它的命令定义也刻意把两步拆开:[`/speckit.specify`](https://github.com/github/spec-kit#available-slash-commands) 定义 Requirements 与 User Stories,`/speckit.plan` 才加入技术栈和实现方案。 这条边界比“Spec 更大、Plan 更细”准确: - Spec 约束产品行为、范围、非目标、兼容性和验收标准; - Plan 根据现有代码与技术约束,选择实现路径; - Tasks 再把路径切成可以执行和跟踪的工作单元。 例如“增加 GitHub OAuth 登录”,Spec 应该先回答:哪些用户能用,原有密码登录是否保留,账号冲突怎样处理,哪些场景不支持,什么行为算验收通过。至于使用哪一个 OAuth Library、用户表是否增加 Identity 表、Callback Route 放在哪个 Module,是 Plan 的问题。 如果把后者直接写进 Spec,技术方案一变,需求也跟着抖;如果把前者留给 Plan,Agent 可能交付一个实现精致、产品含义却完全不对的 GitHub 登录。 Spec 也不是越厚越好。一个只改错误文案的小任务,Prompt 里的两条约束已经可以充当 Spec-lite。只有当需求会跨团队、跨多轮或需要长期追踪时,独立 Spec 才开始值回维护成本。GitHub Spec Kit 展示的是一套完整 SDD 流程,不是每次提交都必须举行的开工仪式。 ## Goal 与 Plan 的分界:一个固定终点,一个允许改路 Goal 最容易被误认为“更坚定的 Plan”。实际上,它不需要规定步骤。 OpenAI Cookbook 把 [Codex Goal](https://developers.openai.com/cookbook/examples/codex/using_goals_in_codex) 定义为跨 Turn 持续存在的目标:结果应该是什么,用什么证据检查,哪些约束必须保持。路径不确定时,Codex 可以依据新证据继续选择下一步;完成条件成立、预算耗尽或遇到真实阻塞时,循环才结束。 因此,两者对变化的态度正好相反: - Goal 尽量稳定。测试失败不能靠降低门槛“解决”; - Plan 必须能变。新发现应该推动路线修订; - Evidence 每轮刷新。文件、测试、Benchmark 与产物决定离终点还有多远。 Claude Code 的 [`/goal`](https://code.claude.com/docs/en/goal) 也体现了同一分工:当前 Agent 每完成一轮,独立的小模型根据 Goal 与对话证据回答 Yes 或 No,未满足就发起下一轮。它要求的不是步骤表,而是可验证的 Completion Condition。Claude 的 Plan Mode 则负责先研究和提出改法,二者可以同时使用,并不冲突。 Codex 与 Claude 的 Goal 如何判定完成,上一篇[《Codex Goal 如何把“继续”变成完成协议》](/posts/codex-goals-completion-contract/)已经单独拆过。这里真正需要记住的是:**Plan 可以因为证据改变,Goal 只能因为需求改变。** 如果只是道路封闭,就换路线;如果连目的地都换了,那应该让用户重新确认任务,而不是悄悄更新计划。 ## Skill 与仓库规则为什么放不进这条流水线 把 Spec、Goal 和 Plan 画成一条从需求到执行的链路还算自然,但 Skill 与 `AGENTS.md` 并不在链路的下一站。它们从侧面影响很多任务。 OpenAI 的 [Codex 定制文档](https://learn.chatgpt.com/docs/customization/overview#skills) 将 Skill 定位为可复用能力:`SKILL.md` 可以带脚本、参考资料与资源,在匹配到某类任务时按需加载。一个“数据库迁移 Skill”可以规定先备份、再 Dry Run、记录 Schema Diff、执行迁移并检查回滚;下次迁移仍然能用。 `AGENTS.md` 则保存持久工作约定。官方的 [`AGENTS.md` 文档](https://learn.chatgpt.com/docs/agent-configuration/agents-md) 用包管理器、测试命令和添加生产依赖前确认等规则举例。这类约定会影响许多任务,却不描述某一个功能的需求,也不会因为某次任务完成而自动消失。Claude Code 里的 `CLAUDE.md` 在概念上承担相近的长期上下文角色。 一个简单判断是: - “这个 OAuth 功能必须保留密码登录”放进 Spec; - “所有认证改动必须运行 `pnpm test:auth`”放进 `AGENTS.md`; - “以后做身份源迁移都按这套审计流程”做成 Skill; - “这一次具体修改哪些文件”写进 Plan; - “所有验收项满足且回归测试通过才停”写成 Goal。 如果同一句规则确实同时出现在两处,不一定是坏事。关键是确认它在每个位置承担什么责任:Spec 中的“不能改公共 API”是产品约束,Goal 中再次引用它是停止条件,Plan 中提到它是实施提醒,验证器则要真正检查它。 ## 六个对象如何在一次任务中配合 把这些对象放回一次真实工程任务,关系会比定义更清楚。 **图 1:Spec、Goal 与 Plan 组成任务主循环;Skill 和仓库规则横向约束工作方式,Validation/Eval 把实现状态反馈给 Goal** ```mermaid flowchart LR S["Spec
行为、边界、验收"] --> G["Goal
当前完成条件"] S --> P["Plan
实现路径"] G --> P P --> W["实施与调查"] W --> V["Validation / Eval
测试、审阅、产物"] V --> D{"Goal 成立?"} D -->|"否"| P D -->|"是"| C["完成"] A["AGENTS.md / CLAUDE.md
长期环境规则"] -.约束.-> P A -.约束.-> W K["Skill
可复用工作流"] -.提供方法.-> P K -.提供方法.-> W ``` 这张图不是强制流程。小任务可以把 Spec 和 Goal 压缩进一句 Prompt,也可以不创建持久 Plan。图里真正不能省略的是最后的证据回边:没有 Validation 或 Eval,Goal 只能听 Agent 自己描述进度,Spec 与 Plan 也无法知道实现是否偏离。 **表 1:截至 2026-08-04 的职责对照;“生命周期”描述典型作用域,不代表所有工具都使用相同存储实现** | 对象 | 核心问题 | 典型生命周期 | OAuth 示例 | 不能替代什么 | | ------------------------- | ---------------------- | -------------------------- | ----------------------------------------- | -------------------- | | Spec / SDD | 要构建什么,边界是什么 | 功能或产品演进期间 | 保留密码登录,不做账号自动合并 | 实现路线、运行时续跑 | | Goal | 什么证据成立时才可以停 | 当前 Task / Session | 认证验收与回归测试全部通过 | 产品需求、详细步骤 | | Plan | 基于现状准备怎样实现 | 当前任务,随发现更新 | Adapter、Migration、Callback、Test 的顺序 | 需求决策、长期规则 | | Task List | 下一批可执行单元是什么 | 一个 Milestone 或迭代 | 拆出 Schema、Route、Test 工作项 | 整体设计与完成协议 | | Skill | 同类任务以后怎样重复做 | 跨 Task 复用 | 身份迁移的审计与回滚流程 | 本次功能需求与终态 | | `AGENTS.md` / `CLAUDE.md` | 在这个环境里长期守什么 | Repository、目录或用户范围 | 包管理器、测试命令、禁改目录 | 某个功能的 Spec | 表里没有把 Validation/Eval 单列成一种文档,因为它更像证据层。测试、静态检查、人工 Review、Benchmark 和验收脚本可以被 Spec、Goal 与 Plan 同时引用,却不能被任何一段自然语言替代。 ## 不同规模的任务,最小组合也不同 这些对象不是集邮项目。更实用的选择方式,是从任务风险与反馈周期反推最小组合。 ### 一次性小改动:Prompt + 仓库规则 + 验证 改文案、修一个明确的 Null Pointer、补一条小测试,通常不值得先维护独立 Spec 与 ExecPlan。Prompt 说清楚预期和边界,Agent 遵守 `AGENTS.md`,最后运行对应检查即可。 ### 中型功能:Spec-lite + Plan + 验收 OAuth、缓存切换或一个跨模块 API,已经有多种合理实现。先写清用户行为、非目标和验收项,再让 Agent 在 Plan Mode 调查代码并提交方案。只要实现仍能由人掌控下一次决策,未必需要 Goal 自动续跑。 ### 长任务且路径不确定:Spec + Goal + Living Plan + Eval 大型迁移、性能调优、间歇性故障和研究复现,终点可以定义,路径却会被每轮证据改写。这正是 Goal 与 ExecPlan 同时有价值的场景:Goal 固定完成协议,Living Plan 保存当前路线、发现与决策,Eval 提供继续或停止的依据。 ### 反复出现的任务:把稳定部分提炼成 Skill 当第三次复制同一套发布、审计或迁移步骤时,再把稳定方法抽成 Skill。Skill 应复用流程,不应把上一次任务的业务目标一起封存。否则“可复用能力”很快会变成一份过期 Plan。 ## 最容易踩的四个替代错误 第一,用 Plan 代替 Spec。Agent 把文件、函数和步骤写得很具体,看起来已经想清楚了,但“用户到底要什么”仍然由实现者临场决定。 第二,用 Goal 代替 Plan。“所有测试通过”可以防止过早停止,却不会告诉 Agent 怎样迁移数据、怎样回滚,以及先验证哪个高风险假设。 第三,用 `AGENTS.md` 堆所有知识。长期规则会被一次性需求污染,真正每次都该遵守的命令反而淹没在大段功能背景里。 第四,把 Skill 当成完成证明。Skill 只能提高流程复用和执行一致性;它被正确调用,不代表本次产物已经通过验收。操作手册翻到了最后一页,机器不一定真的修好了。 ## 总结 Plan、Goal 与 Spec 看起来相似,是因为它们都需要描述目标、约束和验证;真正的区别不在 Markdown 语法,而在**谁读取它、何时生效、允许怎样变化**。 Spec 是需求契约,应该在需求变化时更新;Goal 是当前任务的停止协议,应该在证据成立时完成;Plan 是可修订的路线,应该在新发现出现时改变。Skill 保存跨任务可复用的方法,`AGENTS.md` 或 `CLAUDE.md` 保存跨任务持续生效的环境规则。Validation 与 Eval 则负责让这些自然语言最终碰到现实。 下一次再遇到一个长任务,可以先问五个问题:用户行为是否已经写清,完成是否有可检查证据,路径是否需要先调查,流程以后会不会重复,哪些规则应该对整个仓库长期生效。答案会自然落到 Spec、Goal、Plan、Skill 和 `AGENTS.md`,而不是先建五个文件再往里填内容。 导航、终点、合同、操作手册和交通规则都很有用。麻烦通常不是缺少其中一个,而是拿着交通规则当地图,然后期待它告诉你什么时候已经到家。 ## 参考资料 - [OpenAI Cookbook:Using PLANS.md for multi-hour problem solving](https://developers.openai.com/cookbook/articles/codex_exec_plans) - [OpenAI Cookbook:Using Goals in Codex](https://developers.openai.com/cookbook/examples/codex/using_goals_in_codex) - [OpenAI Codex:Developer commands](https://learn.chatgpt.com/docs/developer-commands) - [OpenAI Codex:Customization / Skills](https://learn.chatgpt.com/docs/customization/overview#skills) - [OpenAI Codex:Custom instructions with AGENTS.md](https://learn.chatgpt.com/docs/agent-configuration/agents-md) - [Anthropic:Choose a permission mode](https://code.claude.com/docs/en/permission-modes) - [Anthropic:Keep Claude working toward a goal](https://code.claude.com/docs/en/goal) - [GitHub Spec Kit](https://github.github.com/spec-kit/) - [GitHub Spec Kit:Specification-Driven Development](https://github.com/github/spec-kit/blob/main/spec-driven.md)
https://www.mkshell.com/posts/claude-tag-shared-agent-runtime/ https://www.mkshell.com/posts/claude-tag-shared-agent-runtime.md 2026-08-04 # Buzzword解读|Claude Tag 不就是能读群聊的 AI 吗? “那不就是一个能读取全量群信息的 AI 而已嘛?” 第一次看到 Claude Tag 时,这个判断很自然。入口还是 Slack,操作还是 `@Claude`,输出也还是一条回复。把包装拆掉,好像就是给聊天机器人多开了几份群聊记录。 这个理解不算错,但只说到了输入。就像把数据库说成“能读取硬盘的程序”:动作确实发生了,却没有解释事务、权限和并发为什么值得单独设计。 Claude Tag 真正想解决的不是“怎样让模型多看几条消息”,而是另一个问题:**怎样让一个 Agent 以团队成员的身份,进入组织的信息流,在共同可见、可干预、可审计的边界内持续做事。** > **数据快照** > > - 最后核验:2026-08-04 > - 产品状态:Claude Team 与 Enterprise 的公开测试版,目前运行在 Slack > - 主要来源:[Anthropic 发布公告,2026-06-23](https://www.anthropic.com/news/introducing-claude-tag)、[Claude Tag 官方文档](https://claude.com/docs/claude-tag/overview) > - 讨论边界:本文分析频道中的 Claude Tag,不把个人 DM、Claude Code 和 Cowork 混为一谈 > - 证据限制:本文没有 Claude Tag 的独立基准测试,产品效果判断以官方机制说明为主,不把厂商自报的内部采用率当成通用能力结论 ## 先说结论:群聊是入口,不是产品的全部 Claude Tag 可以读取群聊信息,但把它定义成“能读取全量群信息的 AI”会漏掉四个更关键的部分: - 同一频道的人共同使用和纠正一个 Agent 会话; - Agent 使用组织配置的独立身份,而不是借用提问者的个人权限; - 它能通过受控连接调用代码库、数据仓库和监控等外部系统; - 任务、记忆和定时工作可以跨过一次问答继续存在。 因此,更准确的一句话是: > **Claude Tag 是以 Slack 为协作界面、以频道和工作区为权限与记忆作用域、以临时沙箱为执行环境的团队 Agent。** 如果只给它 Slack 读取权限,不连接工具,不配置记忆、Routine 和团队身份,它确实很容易退化成“高级搜索 + 群聊总结机器人”。Claude Tag 的价值不是由 `@Claude` 这个入口自动产生的,而是由入口后面那套执行和治理机制产生的。 ## “读取全量群信息”这个前提,本身也不准确 “全量”听起来像 Claude Tag 会吞下整个 Slack 工作区,然后随时从无限上下文里找答案。官方文档描述的机制没有这么魔法。 当 Claude Tag 在一条已有线程中被提及时,它会读取自己的线程和频道;对于提及之前已经很长的线程,当前文档写明它最多取得从线程开头算起的 50 条消息,过长线程中靠近提及位置的新消息反而可能落在窗口外。它也能按关键词搜索公开频道,但这不等于一次性把所有消息塞进模型上下文。[官方因此建议重述关键条件](https://claude.com/docs/claude-tag/concepts/how-it-works#conversation-context),而不是假设它已经“看过一切”。 Memory 也不是聊天记录的另一份无限副本。官方把它定义成经过整理的笔记:团队可以明确要求记住某条规则,Claude 也会保存工作中识别出的决定;需要回看历史 Session 时,它可以读取记录,但不能对所有 Session 做全文搜索。[公开频道产生的 Memory 可在工作区共享,私有频道则写入自己的隔离存储](https://claude.com/docs/claude-tag/users/memory)。 所以 Claude Tag 的上下文更像三层结构: - 当前线程和频道提供眼前的讨论; - Workspace search 按需寻找公开消息; - Memory 保存提炼后的稳定规则与决定。 这三层都有范围,也都会过期或遗漏。它们提供的是**受作用域约束的组织上下文**,不是“全知全量群聊”。 ## 它和群聊总结机器人,差在闭环而不是阅读量 只看界面,两者都在群里回复;把一次任务从头走完,差别就出来了。 **表 1:群聊总结机器人与 Claude Tag 的机制对比;Claude Tag 一栏依据 2026-08-04 核验的官方文档** | 维度 | 群聊总结机器人 | Claude Tag | | ------ | ------------------------------------ | ---------------------------------------------------- | | 上下文 | 当前提问附带的消息或搜索结果 | 线程、频道、Workspace search 与作用域 Memory | | 身份 | Bot 身份回复,外部操作常借用用户授权 | 频道任务使用组织配置的 Agent 服务账号 | | 工具 | 主要读取与生成文本 | 可连接代码库、监控、数据仓库、工单和自定义 HTTP API | | 执行 | 一次请求,一次回答 | 在线程对应的沙箱中规划、调用工具、运行代码并交付产物 | | 协作 | 提问者与 Bot 对话 | 频道成员都能查看、接手和中途纠正同一任务 | | 持续性 | 对话结束即结束 | 可保留线程与 Memory,也可由定时、频道或仓库事件触发 | | 治理 | 重点是消息可见范围 | 还要管理凭据、网络出口、频道作用域、费用和审计记录 | 这张表里最重要的一行不是“上下文”,而是“身份”。上下文让 Agent 知道发生了什么;身份和权限才决定它能不能去做什么。没有后者,读得再多也只是一个消息灵通的围观群众。 ## 一条 `@Claude` 消息背后发生了什么 根据[官方 Session 生命周期](https://claude.com/docs/claude-tag/concepts/how-it-works#lifecycle-of-a-request),频道中的一次任务不是直接把消息丢给模型再等回复。Slack 线程会启动一个独立 Session,Anthropic 为它创建临时沙箱;Agent 在沙箱中拆解任务、运行代码并调用工具,结果再回到原线程。 **图 1:Claude Tag 从 Slack 任务到外部工具和结果回传的执行链路** ```mermaid flowchart LR A["Slack 线程"] --> B["线程 Session"] B --> C["临时沙箱中的 Agent loop"] C --> D["Agent Proxy"] D --> E["代码库、监控、数据仓库"] E --> C C --> F["回复、文档、图表或 PR"] F --> A G["频道权限与 Memory"] --> B H["定时、频道或仓库触发"] --> B ``` 这条链路揭示了三个容易被 Buzzword 遮住的工程事实。 第一,**线程持久,沙箱不持久**。线程安静后,临时沙箱会被释放;新的回复到来时再重建。Slack 中的对话、已经发布的文件和已经推送的分支可以保留,只存在于沙箱里的文件不能。[官方文档明确区分了两者的生命周期](https://claude.com/docs/claude-tag/concepts/how-it-works#what-survives-between-replies)。 第二,**凭据不直接交给模型或沙箱**。组织 Owner 配置的凭据保存在独立存储中,外部请求经过 Agent Proxy 时才按规则注入;目标地址没有命中允许规则,请求就会被阻断。[Agent Identity 文档](https://claude.com/docs/claude-tag/concepts/agent-identity#agent-proxy)把代理层、凭据注入和网络放行规则拆得很清楚。 第三,**Session 属于线程里的团队**。任务运行时,频道成员可以直接在原线程补充约束或纠正方向,不需要由最初提问的人重新发起。官方称它为 Multiplayer,但用人话说,就是“这不是你的私聊任务,而是大家都看得见、接得上的团队任务”。 ## Agent Identity 才是容易被低估的变化 传统个人 AI 助手通常由员工通过自己的 Claude 账号和个人 Connector 访问外部系统。Claude Tag 在频道中则由 Slack 任务触发组织配置的 Agent Identity,再通过该作用域允许的连接访问外部系统。 这不是换了一个登录按钮。它改变了三个责任问题: - **谁有权使用**:频道里的成员共享同一组 Agent 能力,而不是各自重复配置 Connector; - **外部系统看到谁**:提交、Pull Request 和请求归因到 Agent 自己的服务账号; - **出了问题查谁**:审计记录既能追踪 Agent 做了什么,也能追到哪位成员发起了任务。 [官方文档明确区分了频道与 DM](https://claude.com/docs/claude-tag/concepts/agent-identity):频道使用组织配置的 Agent Identity,DM 使用个人 Claude 账号和个人 Connector。也就是说,“在哪里 `@Claude`”会改变它拿谁的权限做事。 这也是为什么 Claude Tag 更像企业 Agent 基础设施,而不只是 Slack 插件。企业真正难的从来不是让模型生成一段答案,而是回答这些不太性感、却绕不过去的问题:上下文属于谁,权限怎样给,副作用怎样限制,结果怎样留下,出了事怎样审计。 ## 用线上故障看出“读消息”和“做任务”的差别 假设支付服务的错误率突然升高,事故频道里已经有人贴出用户反馈、值班判断和最近部署时间。 如果 AI 只读群聊,它能总结“大家怀疑重试策略”,但这个结论仍然来自聊天中的二手判断。它不知道真实指标是否上升,也不能确认代码是否在同一时间发生变化。 如果管理员已经为该频道配置了监控、代码库和历史事故资料,Claude Tag 才有机会把任务继续往下做:读取线程形成调查起点,查询监控确定异常时间,检查近期提交寻找相关变化,运行复现或测试,最后把证据、未确认项和 Draft PR 放回线程。团队成员还可以在中途补充“不要执行生产回滚”或要求先交给 On-call 确认。 注意,这只是依据产品机制给出的**示例路径**,不是本文实际运行过的事故实验。连接存在不代表诊断一定正确,能够开 PR 也不代表 PR 值得合并。Claude Tag 缩短的是“讨论—取证—执行—回传”的路径,不会自动消除模型误判、数据质量问题和人工审核。 ## 它不是新一代模型,更像 Agent 的组织层 Anthropic 在发布稿中把 Claude Tag 称为 Claude Code 的一种演进。这个描述比“新的超级员工”准确:模型负责理解和决策,Claude Code 同源的执行引擎负责在沙箱中工作,而 Slack、Agent Identity、Memory、Routine 和 Audit 把个人 Agent 接进团队协作。 可以把它拆成三层: - Slack 是协作和控制界面,问题、进度、纠正与结果都留在团队正在工作的地方; - Agent loop 是执行层,负责规划、调用工具、验证并生成产物; - Identity、Proxy、Memory 与 Audit 是组织治理层,决定它能访问什么、代表谁行动、留下什么记录。 Anthropic 在发布稿中还称,其产品团队 65% 的代码由内部版本 Claude Tag 创建。这个数字能证明 Anthropic 自己在高强度使用它,却不能直接证明其他团队能获得相同产出:发布稿没有给出代码量口径、接受率、返工成本、任务难度分布或独立复现实验。因此本文不拿它当性能 Benchmark,只把它当厂商披露的内部采用情况。 ## 什么情况下值得用,什么情况下只是多套一层 Claude Tag 最适合同时满足三个条件的任务:上下文原本就在 Slack,完成任务需要跨越多个组织工具,而且过程应该由团队共同查看和干预。例如事故调查、Bug 分诊、项目状态追踪、数据问答和周期性报告,都符合这个结构。 反过来,以下任务不一定需要 Claude Tag: - 只要总结一条短线程,普通 Slack AI 或一次聊天已经够用; - 工作主要发生在个人本地代码环境,Claude Code 的权限模型更直接; - 信息高度敏感,却还没有清晰的频道边界、服务账号和审计流程; - 任务包含发布、删除、回滚等不可逆动作,但系统没有可靠的人工审批门。 还有一个现实风险:Memory 会过期,频道权限可能配得过宽,Routine 也可能把一次性的错误判断变成周期性噪音。官方提供了作用域、网络 allowlist、费用上限和审计能力,但“提供控制项”不等于“组织已经治理好”。上线前仍要从私有频道、只读连接和低风险任务开始,明确哪些动作必须由人确认。 ## 总结:判断它是不是噱头,先拿掉 `@Claude` 再看 Claude Tag 的新意不在 `@`,也不在模型突然能读群聊。把 Slack 界面拿掉之后,剩下的才是关键:团队共享的 Session、独立 Agent Identity、受控工具调用、作用域 Memory、异步触发和可审计产物。 所以,“一个能读取群信息的 AI”抓住了它最显眼的入口,却没有抓住产品边界。更准确的判断是: > **Claude Tag 把 Slack 从消息来源变成了团队 Agent 的协作入口,并用身份、权限、记忆和审计补上从回答问题到执行工作的中间层。** 至于它在你的团队里是 Agent Runtime,还是昂贵一点的总结机器人,可以做一个很简单的检查:拿掉外部工具、团队身份、持续任务和审计之后,价值还剩多少?如果几乎没变,就不必追 Buzzword;如果任务本来就卡在这些组织接口上,它才真正有用。 ## 参考资料 - [Anthropic:Introducing Claude Tag,2026-06-23](https://www.anthropic.com/news/introducing-claude-tag) - [Claude 官方文档:Work with Claude Tag](https://claude.com/docs/claude-tag/overview) - [Claude 官方文档:How Claude Tag works](https://claude.com/docs/claude-tag/concepts/how-it-works) - [Claude 官方文档:How agent identity works](https://claude.com/docs/claude-tag/concepts/agent-identity) - [Claude 官方文档:Security and data handling](https://claude.com/docs/claude-tag/concepts/security-and-data) - [Claude 官方文档:What Claude Tag remembers](https://claude.com/docs/claude-tag/users/memory) https://www.mkshell.com/posts/codex-goals-completion-contract/ https://www.mkshell.com/posts/codex-goals-completion-contract.md 2026-08-03 # Cookbook解读|Codex Goal 如何把“继续”变成完成协议 用 Coding Agent 做一个稍长的任务,很容易出现这种对话: ```text Agent:主体功能已经完成。 你:测试跑了吗? Agent:还有两个失败。 你:继续。 Agent:修好了一个。 你:继续。 ``` 问题不一定是 Agent 不会修,而是谁都没有事先说清楚:**什么时候才算真的做完?** OpenAI Cookbook 的 [Using Goals in Codex](https://developers.openai.com/cookbook/examples/codex/using_goals_in_codex) 正在解决这件事。它给 Codex 增加的不是一句自动重复的“继续”,而是一份持续存在的完成协议:目标是什么,用什么证据验收,哪些东西不能破坏,没达标时怎样继续,什么时候应该承认阻塞。 先说结论: > Goal 不是更长的 Prompt,也不是放开权限让 Agent 一直跑。它把“完成条件”保存为任务状态,让 Codex 在每轮结束后检查真实证据,再决定继续、完成或停在明确的边界上。 把 Claude Code 的 `/goal` 放到旁边会更容易看清这个设计。两者都想消灭人工催促,却把“谁来判定完成”放在了不同位置:Codex 由工作的 Agent 审计文件、测试和产物;Claude 则让另一个小模型根据对话记录做 Yes/No 判定。前者能接触更完整的现场,后者把执行者和裁判分开,但裁判看不到工作区。 > **数据快照** > > - 最后核验:2026-08-03 > - 主要对象:OpenAI Cookbook 的 Using Goals in Codex,以及 Anthropic 的 Claude Code `/goal` 文档 > - Codex Cookbook 标注的最低版本:Codex `0.128.0` > - 本文比较公开的控制机制,不提供两种 Goal 的成功率、成本或质量排名 ## Goal 解决的不是“做什么”,而是“何时停止” 普通 Prompt 描述下一步动作:修复测试、解释报错、修改一个组件。Agent 完成本轮后就把控制权还给用户。Goal 描述的则是一个需要持续成立的终态: ```text /goal 将 checkout benchmark 的 p95 延迟降到 120 ms 以下, 同时保持 correctness suite 全部通过 ``` 只把延迟从 180 ms 降到 135 ms,不算完成;延迟达到 110 ms 但正确性测试失败,也不算完成。下一步到底是继续分析热点、撤销错误优化,还是重新跑基准,要由刚得到的证据决定。 Cookbook 因此把两种交互概括成两条不同的路径:Prompt 是“提问、工作、返回结果、等待”,Goal 则是“工作、检查、继续或完成”。 这里多出来的不是一个步骤,而是一条控制回边。目标仍然有效,Agent 就不该因为一轮回复写完了而默认任务结束。 **图 1:Codex Goal 把持久目标、证据审计和续跑条件组成闭环;权限与预算仍然限制每一轮能够做什么** ```mermaid flowchart LR S([开始:用户定义 Goal]) --> A["用户定义 Goal
终态、证据、约束"] A --> B["Codex 工作一轮"] B --> C["检查文件、测试
日志、Benchmark、产物"] C --> D{"完成条件成立?"} D -->|"是"| E([结束:标记完成]) D -->|"否,仍有可行路径"| F["选择下一步"] F --> B D -->|"预算耗尽或需要输入"| G([结束:停止并报告边界]) ``` 这张图有两个容易忽略的地方。第一,Goal 不替 Codex 选择固定步骤,它只固定完成标准;第二,循环不是无条件自转。[Cookbook 明确说明](https://developers.openai.com/cookbook/examples/codex/using_goals_in_codex#what-changes-when-a-goal-is-active),续跑只会发生在当前轮结束、任务空闲、没有排队的用户输入,而且 Goal 仍处于活动状态并在预算内的时候。没有工具调用的续跑还会被抑制,避免 Agent 只用语言反复说“我会继续”。 所以,Goal 更像恒温器,而不是定时电风扇。它关心的是温度有没有到 24℃,不是每隔五分钟机械地转一圈。 ## 一份可执行的 Goal 至少要回答六个问题 “把项目做好”当然是目标,可惜不是一个能验收的 Goal。[Cookbook 把强 Goal 拆成六部分](https://developers.openai.com/cookbook/examples/codex/using_goals_in_codex#how-to-write-a-goal):结果、验证面、约束、边界、迭代策略和阻塞出口。 **表 1:根据 OpenAI Cookbook 整理的 Goal 完成协议;示例是说明性写法,不代表命令已经在本文运行** | 要素 | 要回答的问题 | 认证模块迁移示例 | | -------- | -------------------- | ------------------------------------------------------ | | 结果 | 最终必须有什么变化 | 所有旧 API 调用完成迁移 | | 验证面 | 什么证据能够证明它 | `python scripts/verify_auth.py` 输出 `AUTH_GOAL: PASS` | | 约束 | 达标时不能牺牲什么 | 测试通过,公共 API 行为不变 | | 边界 | 可以改哪里、使用什么 | 只修改 `src/auth/` 与对应文档 | | 迭代策略 | 每轮怎样选择下一步 | 记录失败原因,只处理当前最高影响问题 | | 阻塞出口 | 何时不该继续消耗资源 | 验证器不可运行或没有合法路径时报告 blocker | 按这六项组合,一个更完整的 Goal 可以写成: ```text /goal 完成 auth 模块的新 API 迁移,由 `python scripts/verify_auth.py` 退出码为 0 且输出 `AUTH_GOAL: PASS` 验证,同时保持现有测试和公共 API 行为不变。 只修改 `src/auth/` 与对应文档。每轮记录验证结果、失败原因和 下一项最小修改。如果验证器无法运行,或在上述边界内没有可行路径, 停止并报告已尝试方案、剩余问题和解除阻塞所需的输入。 ``` 这个写法没有规定 Agent 先改哪个函数,却把“怎样才算完成”锁住了。它既给探索留空间,也避免 Agent 用“主要功能已经完成”绕过失败的验收项。 多个条件最好继续收敛到一个不可随意修改的验证器。与其让模型从测试、覆盖率、Git Diff 和旧调用搜索的长输出里自行拼结论,不如让脚本统一给出: ```text tests: PASS old_api_calls: PASS public_api: PASS protected_files: PASS AUTH_GOAL: PASS ``` Goal 负责读取结果并控制是否续跑,验证器负责把真实状态压成清晰信号。两者放在一起,才更接近工程上的验收,而不是一次措辞更坚定的自我评价。 ## Codex 把 Goal 保存在哪里,决定了它能做什么 [Cookbook 将 Codex Goal 定义为持久化的 thread-scoped state](https://developers.openai.com/cookbook/examples/codex/using_goals_in_codex#how-goals-are-designed-in-codex),也就是属于当前任务的状态。它不是全局记忆,不会自动变成整个项目的长期规则,也不等同于 `AGENTS.md`。 这份状态记录目标、生命周期、预算和进度信息。目标活动时,Codex 可以继续读取文件、运行命令、检查 Diff、查看测试与生成产物,再用这些证据审计是否完成。用户仍然可以用 `/goal pause`、`/goal resume` 和 `/goal clear` 控制生命周期;到达预算上限只表示应该停止实质工作并汇报进度,不表示目标已经完成。 这个边界很实用: - `AGENTS.md` 负责“这个仓库长期应该怎样工作”; - Skill 或工作流负责“这一类任务通常怎样做”; - Goal 负责“当前任务满足什么证据才可以停”; - 权限与沙箱负责“哪些动作允许执行”。 它们不是四种写法不同的 Prompt。操作手册不能代替验收标准,验收标准也不会自动授予删除文件、访问网络或部署生产环境的权限。 ## Claude Code 也有 `/goal`,但裁判坐在另一间房里 Anthropic 的 [Claude Code `/goal` 文档](https://code.claude.com/docs/en/goal) 描述了另一种闭环。每轮 Claude 工作结束后,一个默认使用小型快速模型的独立 evaluator 会读取 Goal 条件和当前对话,回答 Yes 或 No。回答 No 时,理由进入下一轮;回答 Yes 时,Goal 清除并记录为已达成。 这相当于把修车师傅和质检员分开。好处是执行模型不能只凭自己一句“已经完成”直接放行。限制是质检员隔着玻璃,只能看维修记录,不能自己打开引擎盖。 Claude 的 evaluator 不会运行命令,也不会读取文件。它只能看到工作 Agent 已经展示到 transcript 里的证据。因此,同一个验收条件在两边有不同的可靠写法: ```text 不够稳: /goal 所有认证测试通过 更容易判定: /goal `pytest tests/auth -q` 退出码为 0, 并且完整汇总行与 `AUTH_GOAL: PASS` 已出现在当前对话中 ``` **表 2:截至 2026-08-03 的公开机制对比;比较的是证据与控制位置,不是质量排名** | 维度 | Codex Goal | Claude Code `/goal` | | ------------------ | -------------------------------------- | ------------------------------------------- | | 目标范围 | 当前任务的持久状态 | 当前会话的 Goal | | 完成判定 | 工作 Agent 根据证据进行完成审计 | 独立小模型回答 Yes/No | | 判定时能否使用工具 | 工作 Agent 可以检查文件、测试与产物 | Evaluator 不能调用工具 | | 主要证据 | 当前工作区与任务中的实际证据 | 已经出现在对话中的证据 | | 生命周期控制 | 查看、暂停、恢复、清除,并记录预算状态 | 设置、查看、清除;恢复会话时可恢复活动 Goal | | 主要风险 | 做事者同时参与自我验收 | 裁判可能被不完整 transcript 误导 | 这不是“能用工具一定更好”或“独立裁判一定更客观”的单选题。Codex 的证据面更宽,却仍需防止自我确认;Claude 分离了执行与判定,却可能因为关键测试结果没有进入对话而缺证。两种机制分别减少了一类错误,也分别留下了另一类错误。 更稳健的组合仍然是:执行 Agent 访问真实环境,不可随意修改的验证器给出硬信号,独立 Judge 处理文档质量、需求覆盖等语义问题,隐藏或组合测试防止只对公开指标投机。 ## Goal 最危险的失败不是停不下来,而是错误地停下来 Goal 让 Agent 多跑几轮,并不会自动让结果更真实。只要完成条件是一个有漏洞的代理指标,Agent 就可能非常努力地满足指标,同时偏离原始意图。 例如: ```text /goal 让所有公开测试通过 ``` 这句话没有保护测试文件,没有要求实现真实规格,也没有检查未公开输入。最短路径可能是修改测试、为固定样本写特殊分支,或者绕开真正应该实现的模块。循环越勤奋,错误方向反而可能走得越远。 因此,完成协议还需要保护自己的尺子: - 验证器、测试和阈值不能由工作 Agent 随意降低; - 除了显式测试,还要检查组合行为与隐藏样本; - 主观质量交给独立 Review,而不是伪装成布尔条件; - 长任务设置预算与阻塞出口,避免无意义重试; - 研究任务把“确认、近似、代理证据、阻塞、未知”分开记录。 [Cookbook 的 Deep Hedging 案例](https://developers.openai.com/cookbook/examples/codex/using_goals_in_codex#using-goals-for-complex-research-reproducing-a-quant-paper)正好说明最后一点。Codex 能重建定价和对冲机制、训练替代策略并生成多个结果,但原始随机种子、训练路径、Optimizer State 和 Checkpoint 并不完整。因此,最诚实的终态是“部分、近似复现”,而不是把一张相似图表写成“精确复现”。Goal 的价值不只是推动 Agent 继续做,也包括规定它何时必须承认证据到此为止。 ## 什么时候该用 Goal,什么时候只要一句 Prompt Goal 适合终点明确、路径不确定的中长任务:性能调优、间歇性测试排查、依赖迁移、多步重构、Benchmark 驱动优化,以及需要交付证据清单的研究。 下面这些任务通常不需要 Goal: - 改一处文案或一行配置; - 解释一个错误信息; - 做一次短 Code Review; - 只需要一个答案,然后希望 Agent 停下来; - “让架构更优雅”这类还没有验收面的模糊目标。 判断方法其实很简单:如果你已经能预见自己接下来会反复输入“继续、再检查一下、还没满足这个条件”,就值得先把这些话写成 Goal;如果一轮完成后本来就需要你做产品判断,自动续跑反而会抢走必要的决策点。 ## 总结 Using Goals in Codex 最值得带走的不是 `/goal` 这条命令,而是一个很朴素的工程判断:**长任务缺的通常不是更大的 Prompt,而是持久、可验证的停止条件。** Codex 把 Goal 放进当前任务的持久状态,让工作的 Agent 可以围绕真实文件、测试、日志、Benchmark 和产物持续审计;Claude Code 把 `/goal` 做成带独立 evaluator 的 Stop Hook,让另一个模型根据对话证据决定是否继续。前者强调证据能力,后者强调判定分离。 无论使用哪一种,都不要只写“做到最好”。先回答四个问题:最终状态是什么,哪个验证器能够证明,哪些约束不能被破坏,失败到什么程度应该停下来报告。然后再决定是否需要独立 Judge、隐藏测试和预算熔断。 Goal 真正改变的不是 Agent 能工作多久,而是谁有资格说“已经完成”,以及这句话后面到底有没有证据。 ## 参考资料 - [OpenAI Cookbook:Using Goals in Codex](https://developers.openai.com/cookbook/examples/codex/using_goals_in_codex) - [Anthropic:Keep Claude working toward a goal](https://code.claude.com/docs/en/goal)
https://www.mkshell.com/posts/gpt-image-prompt-visual-brief/ https://www.mkshell.com/posts/gpt-image-prompt-visual-brief.md 2026-08-03 # Cookbook解读|图像生成 Prompt 为什么更像一份视觉制作说明 “一只猫,赛博朋克,高质量,8K。” 这句话没有错,模型也确实会给你一只看起来挺贵的猫。问题是:猫在画面哪里?它正在做什么?这是头像、海报,还是商品广告?文字要放在哪?如果第一张不满意,下一轮又该改什么? 这些问题没有答案时,Prompt 更像一句愿望。OpenAI Cookbook 的 [GPT Image Generation Models Prompting Guide](https://developers.openai.com/cookbook/examples/multimodal/image-gen-models-prompting-guide) 想做的,是把愿望改写成可以执行、检查和迭代的视觉说明。 先说结论: > 好的图像 Prompt 不靠神秘关键词取胜,而是先定义要交付什么,再说明场景、主体、关键细节与约束;生成后每次只改一个主要变量,并反复声明不能漂移的部分。 把它想成给摄影师、设计师或插画师的一份 Brief(制作说明)会更直观。你不只是在回答“画什么”,还在回答“这张图拿来做什么、怎样算对、哪些地方不能动”。 > **数据快照** > > - 最后核验:2026-08-03 > - 主要对象:2026-04-21 发布的 OpenAI Cookbook 指南及其 GitHub `main` 分支 Notebook > - 指南示例模型:`gpt-image-2` > - 本文不复跑 Notebook,也不比较模型成功率;文中的结构模板是对官方方法的分析性整理,不是 OpenAI 规定的固定语法 ## 关键词没有失效,失效的是“把关键词当控制面板” 早期的图像 Prompt 很容易写成搜索框: ```text cute cat, realistic, high quality, 4k ``` 这组词表达了对象和模糊的质量偏好,却把大量决定权留给模型。模型需要自己猜场景、动作、构图、光线、媒介和用途。生成结果不好时,我们也很难判断该改哪个变量,只好继续追加 `masterpiece`、`ultra detailed`,像是再按两次电梯按钮就能让它来得更快。 Cookbook 给出的原则并不是“Prompt 必须很长”。它明确说明:短 Prompt、自然段、JSON-like 结构、指令式写法和标签式写法都可以工作;生产环境更应该选择容易扫读和维护的模板,而不是追求聪明的语法。真正重要的是[意图和约束是否清楚](https://developers.openai.com/cookbook/examples/multimodal/image-gen-models-prompting-guide)。 所以,关键词仍然有用。`watercolor`、`top-down`、`soft diffuse light` 都是有效的视觉信号。问题只是它们不能替你回答:**最终要交付一张什么图?** ## 先定义成品,再描述画面 同一只猫,做聊天表情、宠物食品广告和儿童绘本插图,Prompt 不应该只是换几个风格词。用途会改变画面比例、留白、细节密度、文字位置和模型可以自由发挥的范围。 Cookbook 建议按照“背景或场景 → 主体 → 关键细节 → 约束”的稳定顺序组织信息,并把广告、UI Mockup、信息图等用途一并写入 Prompt。本文把这套方法整理成下面七个问题。 **表 1:从 Cookbook Prompting Fundamentals 整理出的视觉制作说明字段;字段顺序可调整,不是固定语法** | 字段 | 要回答的问题 | 示例 | | ---------- | -------------------------- | ---------------------------------------- | | 交付目标 | 这张图用在哪里 | 方形聊天表情、竖版海报、移动端 UI Mockup | | 场景 | 事情发生在什么环境 | 公寓窗台、雨后街道、纯白摄影棚 | | 主体与动作 | 谁在做什么 | 灰褐色狸花猫趴着观察窗外 | | 构图 | 元素如何占据画面 | 猫占画面约 70%,正面近景,左侧留白 | | 光线与氛围 | 光从哪里来,画面是什么情绪 | 左侧柔和晨光,低对比度,安静 | | 媒介与质感 | 它看起来像什么载体 | 自然手机照片、平面矢量图、水彩插画 | | 约束 | 什么不能出现,什么不能改变 | 无文字、无边框、保持花纹和眼睛颜色 | 这七项的价值不在于凑齐表格,而在于把模糊偏好变成能检查的条件。简单任务可以只写三四项;布局、文字或身份一致性很重要时,再把对应字段写细。 例如,我们把开头那只猫改成一张聊天表情: ```text Deliverable: A square 1:1 reaction image for a messaging app. Scene and subject: A gray-brown tabby cat sitting on an apartment windowsill, looking mildly annoyed and unimpressed. Composition: Close-up portrait, eye-level view. The cat occupies about 70% of the frame. Keep the background simple and softly blurred. Lighting and medium: Soft indoor morning light from the left. Natural, slightly imperfect phone-photo texture. Constraints: No text, stickers, borders, watermark, or exaggerated cartoon features. Keep the M-shaped forehead marking, green eyes, and white chest fur consistent. ``` 这段 Prompt 不保证一次生成就完美,但失败会变得更容易诊断:构图不对就改构图,身份漂移就重申角色锚点,照片太像棚拍就收紧媒介与光线。控制来自可分解,而不是来自字数。 ## 摄影语言控制观感,不负责模拟光学实验 “85mm 镜头、f/1.4、浅景深”是图像 Prompt 里常见的一组词。它们有用,因为能给模型一个关于取景距离、背景虚化和人像观感的高层提示。但这里很容易从“有用”滑到“精确”。 Cookbook 特别提醒:详细的相机参数可能被模型宽松理解,更适合控制整体观感和构图,不应该被当成精确的物理模拟。因此,与其只写一串器材参数,不如把真正需要的视觉结果也写出来: ```text Medium close-up at eye level. The face is sharp, the background is softly blurred, with natural skin texture and subtle film grain. ``` 光线也是同样的道理。`beautiful lighting` 只表达“希望好看”,`soft coastal daylight from the left, low contrast, natural shadows` 才说明光的方向、硬度和结果。器材名是捷径,视觉现象才是目标。 ## 约束不是负面词列表,而是编辑范围 生成新图时,约束负责阻止模型顺手加戏;编辑已有图片时,它还要划出“允许修改”的边界。 Cookbook 在虚拟试衣、天气变化、物体移除、室内家具替换和多图合成中反复使用同一类表达: ```text Change only X. Preserve A, B, and C. Keep everything else unchanged. ``` 例如,把白天街景改成雪夜,真正的任务不是“生成一张雪夜街景”,而是:只改变天气、光线、阴影、空气与地面湿度,同时保留人物身份、物体位置、透视和相机角度。前一句描述创作,后一句才定义编辑。 这也解释了为什么 `no watermark` 只是约束的一小部分。更重要的约束通常是“不变量”:角色的脸、产品标签、Logo 形状、页面布局、箭头方向、文字内容、镜头位置。只告诉模型不要什么,却不告诉它必须保留什么,编辑仍然可能一路漂走。 ## 一次写完不是目标,小步收敛才是工作流 Prompt 越长,互相冲突的要求越难排查。Cookbook 的建议很务实:先用干净的基础 Prompt 得到方向,再通过小而单一的修改逐步收敛,例如“让光线更暖”“移除多余的树”“恢复原来的背景”。关键细节开始漂移时,要重新写明,而不是假设模型永远记得第一轮的全部约束。 **图 1:从视觉目标到可验收结果的迭代闭环;每轮只修改一个主要变量,并重新声明不变量** ```mermaid flowchart LR S([开始:明确交付需求]) --> A["定义交付目标"] A --> B["写基础 Prompt"] B --> C["生成或编辑"] C --> D["按约束检查"] D -->|"不符合"| E["修改一个变量"] E --> F["重申不变量"] F --> C D -->|"符合"| G["保存结果与 Prompt"] --> Z([结束:沉淀可复用结果]) ``` 这条闭环比“寻找终极 Prompt”更适合生产:失败原因更容易定位,修改效果更容易比较,已经正确的部分也不必每轮重新抽奖。 如果画面里包含文字,还可以把验收条件继续拆细:把原文放进引号,要求只出现一次,说明字体类别、颜色、位置和对比度。对于不常见的品牌名,Cookbook 甚至建议逐字母拼写。即使如此,文字仍然应该在交付前人工核对;“Prompt 写了必须正确”不是验收结果。 ## 不同任务需要不同的说明,而不是同一套华丽词汇 Cookbook 用信息图、广告、漫画、UI Mockup、科学示意图、产品图和精细编辑展示了一个共同规律:先说清楚产物的功能,再补视觉语言。 **表 2:不同图像任务最值得写清的控制点;根据 Cookbook 用例归纳** | 任务 | 首要控制点 | 容易遗漏的约束 | | -------------- | ------------------------------------ | --------------------------------------- | | 广告 | 品牌定位、受众、场景、准确文案 | 无额外文字、无无关 Logo、文案只出现一次 | | 漫画 | 每格一个清晰的动作节点 | 格数、顺序、角色一致性、对白留白 | | UI Mockup | 页面层级、间距、真实组件和状态 | 避免概念艺术感,画面要像可用界面 | | 信息图或科学图 | 受众、教学目标、必需标签、连接关系 | 避免小字和装饰噪声,核对事实准确性 | | 产品图 | 产品几何、材质、标签清晰度、接触阴影 | 不重新设计产品,不改标签,不加水印 | | 精细编辑 | 明确唯一修改对象 | 保持身份、构图、透视、光线和周围物体 | 这里最值得注意的是 UI Mockup 和信息图。把它们写成“漂亮、现代、科技感”,模型可能给你一张设计概念图;把真实组件、数据、标签、层级和画布写进去,才更接近可讨论的设计稿。Prompt 从审美描述变成了 Artifact Spec(产物规格)。 ## Prompt 之外,还有模型参数和人工验收 视觉制作说明能提高可控性,却不能替代完整工作流。 截至指南的 2026-04-21 快照,OpenAI 推荐新工作流默认使用 `gpt-image-2`。指南建议延迟敏感或高吞吐任务先评估 `quality="low"`;小字、密集信息图、近距离人像、身份敏感编辑和高分辨率输出,则应比较 `medium` 或 `high`。这说明质量、延迟、尺寸和输入保真度也是控制面,不能指望 Prompt 单独补偿所有参数选择。 另外,Cookbook 展示的是提示模式和示例,不是每种任务的准确率 Benchmark。它能证明这些结构是官方推荐的实践入口,不能证明某个模板在所有素材、语言、尺寸和模型版本上都稳定成功。真正进入生产,还需要保存输入、Prompt、参数和输出,针对自己的任务建立验收样本。 一个最小的检查清单可以是: - 主体、动作和空间关系是否正确; - 必须出现的文字是否逐字一致; - 人物身份、产品几何和品牌元素是否保留; - 有没有多余文字、水印、物体或装饰; - 本轮修改是否只影响预期变量; - 同一 Prompt 在多个样本上是否仍然可接受。 ## 总结 这篇 Cookbook 真正带来的变化,不是发明了一套新的 Prompt 咒语,而是把图像生成变成一项可以拆解的制作工作。 先定义交付物,再描述场景、主体、构图、光线与媒介;用约束划出创作和编辑边界;生成后一次只改一个主要变量,并重申不能变化的部分。你仍然可以使用关键词、镜头语言和风格词,但它们服务的是视觉目标,不是替代目标。 如果下一张图不知道怎么写 Prompt,可以先不问“还缺哪个高级词”,改问三个问题:**这张图拿来做什么?怎样才算对?哪些地方绝对不能动?** 这三个答案写清楚以后,Prompt 才从一句愿望,变成了一份模型和人都能检查的视觉制作说明。 ## 参考资料 - [OpenAI Cookbook:GPT Image Generation Models Prompting Guide](https://developers.openai.com/cookbook/examples/multimodal/image-gen-models-prompting-guide) - [OpenAI Cookbook:`image-gen-models-prompting-guide.ipynb`](https://github.com/openai/openai-cookbook/blob/main/examples/multimodal/image-gen-models-prompting-guide.ipynb) https://www.mkshell.com/posts/macro-evals-agentic-systems/ https://www.mkshell.com/posts/macro-evals-agentic-systems.md 2026-07-31 # Cookbook解读|Macro Evals 如何找到 Agent 系统的故障根因 一个 Agent 运行失败时,我们还可以打开 Trace,一步步找它在哪里走偏。可要是每天有 1,000 条 Trace,其中 200 条失败,难道真要让工程师逐条欣赏 Agent 的迷惑行为吗? 单条 Eval 能告诉我们“这次工具参数错了”“这次 handoff 不对”,却很难回答另一个更像生产问题的问题:**这些错误是不是同一种系统性故障,它集中在哪些场景,又该先找谁处理?** OpenAI Cookbook 的 [Macro Evals for Agentic Systems](https://developers.openai.com/cookbook/examples/partners/macro_evals_for_agentic_systems/macro_evals_for_agentic_systems) 正在补这一层。它没有继续围着某一条失败 Trace 打转,而是先从整个 Trace 群体中找出重复模式,再沿执行图回溯上游的可疑节点。 先说结论: > Cookbook 把“寻找根因”拆成三步:归并反复出现的故障、按影响排序、沿执行图回溯根因候选。它能把排障范围从海量 Trace 缩小到少数 Agent、工具或 handoff,但最后得到的是调查入口,不是因果证明。 换句话说,这套方法确实在“找根因”,只是它先找出最值得验证的嫌疑对象。照着搭第一版系统很有价值,拿它直接做质量门禁或自动 Root Cause Analysis,就走得太快了。 > **数据快照** > > - 最后核验:2026-07-31 > - 主要对象:2026-05-19 发布的 OpenAI Cookbook 文章及其 GitHub `main` 分支代码 > - 示例数据:1,000 个合成订单,其中 992 个包含可分析的 Trace bundle > - 对照资料:OpenAI、Anthropic 当前 Eval 文档,以及 2025—2026 年 Agent 故障定位研究 > - 本文不复跑 Notebook,也不报告新的聚类结果;对实现的判断来自公开文章与源码 ## 第一步:从单条 Eval 上升到群体模式 这里的 “macro” 容易让人想到 macro-F1,但文章说的不是这个。它指的是从单条运行上升到整个 Trace 群体,观察系统反复以什么方式失败。 文章把 Eval 分成两层: - **Lower-level evals** 评价单个 Agent、工具调用、handoff 和一次完整运行; - **Macro evals** 聚合许多底层 finding,寻找重复问题、集中场景和优先检查的系统组件。 OpenAI 当前的 [Trace grading 文档](https://developers.openai.com/api/docs/guides/trace-grading) 也把 Trace 定义为端到端的决策、工具调用与推理记录,并用它定位编排或行为中的错误。Macro Eval 没有替代这层判断,而是继续追问:当错误出现了几百次以后,我们能不能看见它的分布? **图 1:Cookbook 从单次业务运行到人工排障入口的完整链路;箭头表示分析产物,不表示已经证明因果** ```mermaid flowchart LR S([开始:业务场景与环境变化]) --> A["业务场景
订单与环境变化"] A --> B["Agent 系统运行
工具、handoff、状态"] B --> C["Lower-level Eval
为每条 Trace 产生 finding"] C --> D["Trace Document
压缩场景、路径与终态"] D --> E["Embedding + UMAP
HDBSCAN 聚类"] E --> F["行为模式
频率与严重度排序"] F --> G["执行图回溯
嫌疑节点排名"] G --> H["人工检查
验证、修复、重跑"] --> Z([结束:确定改进动作]) ``` 这条链的价值,是把几千个事件变成少量可讨论的问题族。它的限制也在图里:从 finding 到 pattern 是统计归并,从 pattern 到 suspect 是启发式回溯,只有最后的验证、修复和重跑才可能把“相关”推进到“确实是原因”。 ## 第二步:把一千条订单压成几个问题族 Cookbook 构造了一个电动车订单工作流。定价、合规、供应、工厂路由、排期和放行等 specialist Agent 会在市场与运营条件变化时协作处理订单。公开数据包含 1,000 个合成订单,992 个订单有完整 bundle,因此 Notebook 可以离线读取 Trace 和已保存的 Promptfoo 标签,不需要重新调用模型。 整个分析使用四个面向读者的标签:`case_type`、`run_outcome`、`eval_finding` 和 `behavior_pattern`。 前三项在聚类前已经存在:业务场景、运行结果和底层 Eval finding。最后一项才是聚类得到的行为模式。这样,业务人员可以从订单类型追到风险模式,工程人员也可以从某个模式回到工具、handoff 和状态变化。 ### Trace Document 决定了系统看得见什么 原始 Trace 太长,也混着模型回复、工具输入输出、状态变化和重复日志。文章因此为每次运行构造 `doc_structured_summary`,保留场景、路由、状态转换、handoff、finding 与终态,再把它交给后面的文本模型。 这一步看起来像数据清洗,其实已经是 Eval 设计: - 不记录 handoff,就很难发现路由问题; - 不记录环境信号,就很难发现某个市场状态下的集中失败; - 把多个错误压成一句摘要,后面的 Embedding 也无法把顺序和传播关系重新变回来。 所以文章最值得保留的判断,不是“UMAP 配什么参数”,而是:**Trace 的表示方式决定了 Macro Eval 可以发现什么,也决定了它必然漏掉什么。** ### 聚类流程成熟,但还只是探索性分析 文章采用一条 BERTopic 风格的常见路径: **图 2:Trace Document 经降维、聚类和关键词抽取后形成可读问题模式** ```mermaid flowchart LR S([开始:输入 Trace Document]) --> A["Trace Document"] A --> B["Sentence Embedding"] B --> C["UMAP 降维"] C --> D["HDBSCAN 聚类"] D --> E["关键词抽取"] E --> F["模式命名"] --> G([结束:得到模式标签]) ``` 问题出在实现边界。公开 helper 的 [`reduce_embeddings()`](https://github.com/openai/openai-cookbook/blob/main/examples/partners/macro_evals_for_agentic_systems/helpers/macro_eval_pipeline.py#L384-L428) 默认把 Embedding 降到二维,再直接将二维结果交给 HDBSCAN。二维很适合画散点图,却不一定适合承担最终聚类。[BERTopic 官方降维文档](https://maartengr.github.io/BERTopic/getting_started/dim_reduction/dim_reduction.html) 给出的聚类示例使用 5 维,而它的[可视化文档](https://maartengr.github.io/BERTopic/getting_started/visualization/visualization.html) 才明确把 2 维用于展示。 二维聚类不等于一定错误,但至少应该补做: - 不同随机种子下,问题族是否稳定; - 更换 Embedding、维度和 HDBSCAN 参数后,主要模式是否仍然存在; - 新一批 Trace 能否稳定映射到旧模式; - 聚类结果与人工故障分类的一致性如何。 在这些验证完成前,散点图可以帮助探索,不适合摇身一变成为发布门禁。 ## 聚类找到的是问题族,不是未知根因 从流程图看,系统像是在没有标签的日志里自动发现问题。但源码给出了更窄的边界。 首先,主要聚类对象不是全量线上流量,而是已经带有 failure、review、runtime 或 Promptfoo 失败信号的 Trace。文章自己也说明,这是为了把注意力集中到需要处理的运行上。 其次,Trace Document 已经包含业务场景、终态、finding 和失败窗口等强信号。聚类更像把已知局部告警归并成几个语义相近的问题族,而不是从一片无标签日志中发现完全未知的故障。 最后,公开源码的 [`FAILURE_LABEL_HINTS`](https://github.com/openai/openai-cookbook/blob/main/examples/partners/macro_evals_for_agentic_systems/helpers/macro_eval_pipeline.py#L79-L86) 内置了 `WHEEL_TRIM_CONFLICT`、`BASE_MSRP_DRIFT`、`FULFILLMENT_REROUTE` 等提示;[`_label_from_keywords()`](https://github.com/openai/openai-cookbook/blob/main/examples/partners/macro_evals_for_agentic_systems/helpers/macro_eval_pipeline.py#L2266-L2313) 还会按价格、合规、重试、澄清和供应等关键词命名 Topic。 因此,更准确的说法是: > 这套流程用无监督聚类组织 Trace,但问题空间和最后的可读标签都受已有业务字段、Eval finding 与规则提示引导。 这不是缺点。把几十种局部告警压成五个可以分配 owner 的问题族,本身就很有生产价值。真正需要避免的,是把“归并已知信号”宣传成“自动发现未知根因”。 ## 第三步:用影响分决定先追哪种故障 聚类完成后,文章用下面的公式给行为模式排序: ```text impact_score = prevalence_share × severity_weighted_prevalence ``` 直觉很自然:一个模式出现得越多,其中的失败越严重,就越应该先看。文章也明确说它不是通用风险公式,只是一个 triage score。 这里有两个容易被图表藏起来的限制。 第一,`prevalence_share` 主要是在“已经被标记为失败或需要审查的 Trace”中计算,而不是该问题占全部生产请求的真实发生率。它能回答“问题样本内部谁最多”,不一定能回答“线上谁影响最大”。 第二,严重度来自预先设定的状态权重,没有直接纳入真实损失、受影响用户数、可逆性、合规后果、传播范围和尾部风险。一个罕见但不可逆的合规事故,很可能被高频小故障压到后面。 所以,这个分数适合排调查队列,不适合替代业务风险模型。生产系统至少还要把流量基数、业务损失、置信区间和风险上限放回同一张表里。 ## 第四步:沿执行图回溯根因候选 找到高影响模式后,Cookbook 会重建一张轻量执行图:节点是 Trace 事件,边表示时间顺序、handoff、工具调用与邻近上下文。随后从 failure 或 review anchor 向前回溯,并给上游节点计算: ```text suspect_score = 0.4 × proximity + 0.3 × frequency + 0.2 × bridge + 0.1 × role ``` 公开实现确实按这组固定权重计算[嫌疑节点分数](https://github.com/openai/openai-cookbook/blob/main/examples/partners/macro_evals_for_agentic_systems/helpers/macro_eval_pipeline.py#L859-L906)。文章也很克制:它说这是“先检查哪里”,不是 causality proof。 离失败近、反复出现、连接多个阶段、角色看起来相关,都只能提高怀疑程度。一个日志节点可能只是记录得更完整,一个工具可能同时大量出现在成功和失败轨迹中,最后一个报错节点也可能只是替上游错误背锅。 故障定位本身是一项需要单独评测的任务。2025 年的 [Who&When](https://arxiv.org/abs/2505.00212) 为多 Agent 失败标注了责任 Agent 和决定性错误步骤;论文报告的最佳方法能以 53.5% 准确率找到责任 Agent,却只有 14.2% 的步骤定位准确率。这个结果至少提醒我们:把“先看谁”推进到“谁导致失败”,远比设计一组可解释权重困难。 2026 年的 [Holistic Evaluation and Failure Diagnosis](https://arxiv.org/abs/2605.14865) 进一步把全局 Agent 级诊断与独立的 span 级评测结合起来。它代表的方向不是给整条长 Trace 再问一次大模型,而是保留结构、拆分 span,并对定位器本身计算 category F1、localization accuracy 等指标。 ## 前提:Macro 层会放大底层 Judge 的误差 Macro Eval 的输入并不是真相,而是底层 Eval 产生的 finding。如果 Judge 把正常 Trace 判成失败,Macro 层可能聚类出一个很稳定的“虚假故障”;如果 Judge 漏掉某类错误,后面的聚类算法再高级也无从发现。 OpenAI 当前的 [Evaluation best practices](https://developers.openai.com/api/docs/guides/evaluation-best-practices) 明确要求用 human feedback 校准自动评分,并让测试反映真实生产分布。Anthropic 的 [Demystifying evals for AI agents](https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents) 也建议组合代码、模型和人工 grader,并让 LLM rubric 持续对齐专家判断。 这意味着 Macro Eval 上线前,至少要先回答: - 底层 Judge 与人工专家的 precision、recall 和一致性是多少; - 哪些场景最容易误判或漏判; - Judge、Prompt 或模型版本变化后,历史 Topic 是否还能比较; - 高影响 Topic 是否只是某个 Judge 偏差被重复放大。 Macro 层不是用来掩盖单条 Eval 不可靠的。它会把底层信号规模化,也会把底层错误一起规模化。 ## 把根因候选推进为结论,还缺五道验证 下面五项是本文根据前述证据做的分析性拆解,不是 Cookbook 已经定义或完成的验收体系。 **表 1:Macro Eval 原型的五个补强点;“成功信号”是生产验收建议,不是 Cookbook 已报告结果** | 环节 | 当前原型能回答什么 | 还要补什么 | 一个可检查的成功信号 | | -------------------- | ----------------------- | ------------------------------------------- | ------------------------------------------------- | | Judge 校准 | 哪些 Trace 被标记为问题 | 与人工金标准对照,按场景拆误差 | 关键类别的 precision、recall 和一致性达到预设门槛 | | Pattern Discovery | 问题样本里有哪些语义簇 | 多随机种子、多参数、跨时间稳定性 | 主问题族在重复实验中可复现 | | 成功—失败对照 | 失败内部什么最常见 | 计算行为在成功与失败中的 lift 或 odds ratio | 高风险信号能区分失败,而不是到处都出现 | | Failure Localization | 哪些节点应先检查 | 标注责任 Agent、决定性步骤和可恢复点 | Top-K recall、step accuracy 与置信度校准可接受 | | 修复闭环 | 哪个模式和节点值得怀疑 | 修改、反事实 replay、回归集与线上验证 | 修复后目标故障下降,其他能力没有明显回退 | 其中,成功—失败对照尤其重要。只看失败样本,我们最多知道“失败里经常出现什么”。如果某工具出现在 80% 的失败 Trace,也出现在 90% 的成功 Trace,它就不该因为常见而成为头号嫌疑。 近期研究已经在向闭环前进。[TRACE](https://arxiv.org/abs/2604.05336) 对比成功与失败轨迹,识别缺失能力,再生成定向训练环境并验证训练后的变化;[Tracing Agentic Failure from the Flow of Success](https://arxiv.org/abs/2607.12747) 则学习成功轨迹的动态模式,用偏离程度定位失败步骤。这些论文不等于已经解决生产 RCA,但都说明单独围着失败簇打转,并不是唯一入口。 ## Macro Eval 应该放在 Agent Reliability 的哪一层 Macro Eval 不应该单独成为一座新平台。更合适的做法,是把它放进从业务结果到持续改进的六层链路中。 **表 2:Agent Reliability 的六层 Eval;每一层回答不同问题,不能用上一层分数替代下一层验证** | 层级 | 观察对象 | 主要问题 | 典型产物 | | ----------------- | ------------------------------ | ---------------------------- | ------------------------- | | L0 业务结果 | task success、SLA、真实状态 | 用户要的事情完成了吗 | 业务结果与损失 | | L1 组件 Eval | tool、routing、handoff、policy | 哪个局部行为不符合要求 | finding 与 grader 证据 | | L2 轨迹 Eval | 完整 Trace、错误传播 | 最早从哪里开始走偏 | span 标签与传播路径 | | L3 Macro Analysis | 大量 Trace 的分布 | 什么问题反复出现、集中在哪里 | pattern、slice 与版本回归 | | L4 诊断与验证 | suspect、对照与 replay | 它真的是原因吗 | 反事实结果与修复验证 | | L5 持续改进 | 回归集、监控与 owner | 修复是否持续有效 | 回归门禁、告警与责任归属 | 这六层里,Macro Eval 位于 L3。它的输入依赖 L0—L2 是否记录准确,输出还必须经过 L4 验证,最后由 L5 把已确认故障变成回归集和持续监控。 如果现在要搭第一版,我会先做三件事:保留结构化 Trace 和版本字段;用人工样本校准底层 Judge;把 Macro 输出定位成“调查队列”,明确显示证据、基数和不确定性。等这些稳定后,再讨论自动 owner、影响排序和根因候选。 ## 总结 Macro Evals 抓住了 Agent 系统里的一个真实缺口:单条 Trace 可以解释一次失败,却无法自动告诉我们系统反复以什么方式失败。Cookbook 先把底层 finding 归并成问题族,再按影响排序,最后沿执行图回溯上游节点。这条链确实能把海量日志压缩成少数根因候选。 但压缩不是证明。文本聚类不能自动发现所有未知故障,频率乘严重度不是完整业务风险,固定权重排出的嫌疑节点也不是根因。底层 Judge 没校准时,Macro 层甚至会把误差包装成一张很漂亮的故障地图。 所以,最合适的用法是: > 用 Macro Eval 找到“最应该先验证的根因候选”,用成功—失败对照和结构化定位缩小范围,再用 replay、修复后重跑与回归集回答“它是不是原因、修复是否有效”。 做到这一步,Macro Eval 才不是一个新的观测看板,而是 Agent Reliability 闭环里真正能推动系统变好的那一层。 ## 参考资料 - [OpenAI Cookbook:Macro Evals for Agentic Systems](https://developers.openai.com/cookbook/examples/partners/macro_evals_for_agentic_systems/macro_evals_for_agentic_systems) - [OpenAI Cookbook:`macro_eval_pipeline.py`](https://github.com/openai/openai-cookbook/blob/main/examples/partners/macro_evals_for_agentic_systems/helpers/macro_eval_pipeline.py) - [OpenAI:Trace grading](https://developers.openai.com/api/docs/guides/trace-grading) - [OpenAI:Evaluation best practices](https://developers.openai.com/api/docs/guides/evaluation-best-practices) - [Anthropic:Demystifying evals for AI agents](https://www.anthropic.com/engineering/demystifying-evals-for-ai-agents) - [BERTopic:Dimensionality Reduction](https://maartengr.github.io/BERTopic/getting_started/dim_reduction/dim_reduction.html) - [Who&When:Which Agent Causes Task Failures and When?](https://arxiv.org/abs/2505.00212) - [Holistic Evaluation and Failure Diagnosis of AI Agents](https://arxiv.org/abs/2605.14865) - [TRACE:Capability-Targeted Agentic Training](https://arxiv.org/abs/2604.05336) - [Tracing Agentic Failure from the Flow of Success](https://arxiv.org/abs/2607.12747)
https://www.mkshell.com/posts/graph-engineering-vs-workflow-constraints/ https://www.mkshell.com/posts/graph-engineering-vs-workflow-constraints.md 2026-07-30 # Buzzword解读|Graph engineering 比 workflow 多了什么? 如果 workflow 已经可以包含分支、循环、并行和多个 Agent,为什么还要再造一个叫 Graph engineering 的词? 顺着这个问题继续想,很容易得到一个看似合理的答案:普通 workflow 由工程师写好,Graph engineering 则让 Agent 自己决定图,工程师只负责补上严苛约束,防止它失控。 这个理解已经摸到了“动态路径”和“约束”的重点,但还差两步。第一,图不一定由 Agent 决定;第二,工程师的工作也不只是列禁止事项。真正需要工程化的是一整个**执行空间**:有哪些节点,状态怎样流动,谁有权选择哪条边,失败后回到哪里,以及什么条件下必须停止。 > **数据快照** > > - 最后核验:2026-07-30 > - 术语来源:[LangChain,2026-07-22](https://www.langchain.com/blog/3-years-of-graph-engineering-with-langgraph) > - 主要实现参考:[Claude Code Dynamic Workflows,要求 v2.1.154 或更高版本](https://code.claude.com/docs/en/workflows)、[LangGraph v1 文档](https://docs.langchain.com/oss/python/langgraph/overview) > - 比较边界:本文讨论的是 Agent 的**执行图与编排**,不是知识图谱(Knowledge Graph) ## 先说结论:人设计变化空间,Agent 只决定这一次怎样走 Graph engineering 不是 workflow 的反义词,也不是“让 Agent 自己画图”的简称。 更准确的理解是: > **Workflow 描述任务怎样执行;Graph engineering 则把节点、边、状态、约束、失败恢复和评测一起当成可设计、可执行、可观察的工程对象。** 一个 Graph 可以完全固定,也可以只有路由由 Agent 决定,还可以让 Agent 在运行时生成部分子任务。无论动态到什么程度,外层通常仍有一张由工程师定义的“元图”: - 允许创建哪些节点; - 允许连接到哪里; - 可以读取和修改哪些状态; - 最多运行多少次; - 什么时候必须验证、停止或交给人。 所以,把职责压缩成一句话就是: > **工程师设计“允许怎样变化”,Agent 决定“这一次具体怎样走”,Runtime 保证“再怎么走也不能越界”。** 这里的 Runtime 不只是一段循环代码。它还可能包括 schema 校验、计数器、权限系统、沙箱、checkpoint、重试策略和人工审批。 ## Graph engineering 是新概念,还是 workflow 换了名字 “Graph engineering”作为 Agent 圈的新说法确实很新。LangChain 在 2026 年 7 月 22 日的文章中直言,这个词来自社交媒体的又一轮命名热潮,带有 buzzword 成分;但它描述的问题真实存在:开发者需要在确定性路径和模型自主决策之间找到平衡。[LangChain 对它的定义](https://www.langchain.com/blog/3-years-of-graph-engineering-with-langgraph)是把 Agent 系统表示为图,让构建者能够在需要时把行为限制在更明确的路径中。 图本身当然不新。DAG、状态机、工作流引擎、重试队列和人工审批早就存在。相对新的地方,是图里的一个节点现在可以不只是函数或单次 LLM 调用,而是一个带工具、记忆和内部循环的完整 Agent。LangChain 也把这点视为本轮讨论真正发生的变化。 用最小模型表示,一张 Agent 执行图包含四部分: ```text Graph = Nodes + Edges + State + Runtime semantics ``` - Node 执行工作,可以是代码、工具、一次模型调用、完整 Agent 或人工节点。 - Edge 决定下一步,可以固定,也可以依据状态、模型判断或外部信号动态选择。 - State 保存跨节点共享的信息,并规定不同更新怎样合并。 - Runtime semantics 规定并发、重试、超时、checkpoint、恢复、权限和停止条件。 [LangGraph 的 Graph API](https://docs.langchain.com/oss/python/langgraph/graph-api)把 `State`、Node 和 Edge 暴露为一等公民,并通过 reducer 定义并行或连续更新怎样写回状态。但这只是显式图的一种实现。OpenAI 的 Agent 指南也区分了声明式 Graph 和 code-first 编排:前者预先定义节点和边,后者使用普通编程语言的分支、循环与函数表达同一套控制流。[显式 Graph API 和隐式代码图都成立](https://openai.com/business/guides-and-resources/a-practical-guide-to-building-ai-agents/),差别在表达与治理方式,不在有没有一张可视化画布。 ## Workflow 和 Graph engineering 的差别不在“能不能分支” Anthropic 在《[Building Effective AI Agents](https://www.anthropic.com/engineering/building-effective-agents)》中曾给出一个很窄但好用的区分: - workflow 通过预定义代码路径编排 LLM 和工具; - agent 由 LLM 动态决定自己的过程和工具使用。 但同一篇文章也把 routing、parallelization、orchestrator-workers 和 evaluator-optimizer 都列为 workflow。特别是 orchestrator-workers,子任务本来就是运行时由中央 LLM 动态拆出来的。可见 workflow 并不天然等于“线性固定步骤”。 Graph engineering 和 workflow 真正的差别,不是前者有分支、后者没有,而是观察角度不同。 **表 1:Workflow 与 Graph engineering 的概念对比;这是本文的分析性拆解,不是某个框架的官方术语表** | 维度 | Workflow | Graph engineering | | ------------ | ----------------------------------- | ----------------------------------------- | | 主要问题 | 任务按什么顺序执行 | 执行空间怎样设计和治理 | | 基本对象 | 步骤、条件、循环、并行任务 | 节点、边、状态、Runtime 语义 | | 动态性 | 可以固定,也可以运行时变化 | 可以固定,也可以运行时变化 | | Agent 的位置 | 可以没有 Agent,也可以由 Agent 编排 | Agent 可以是节点、路由器或子图生成器 | | 状态 | 可能只是步骤间变量 | 通常显式考虑 schema、写权限、合并和持久化 | | 异常 | 常按任务或步骤处理 | 还要定义失败类型、恢复边和停止边 | | 质量 | 关注最终任务是否完成 | 同时关注节点、路由、状态转移和最终结果 | | 产物 | 一段流程或可执行程序 | 图结构及其约束、状态契约、运行策略和评测 | 一条线性 workflow 本来就是路径图;带条件的 workflow 是 DAG 或状态机;带重试的 workflow 是有环图;一个 Agent 不断调用工具,则是一个循环子图。 因此,从 workflow 走向 Graph engineering,通常不是把现有流程推倒重来,而是把藏在代码和 Prompt 里的结构显式拿出来问: - 这条边为什么存在? - 谁能决定走这条边? - 进入下一个节点之前,哪些条件必须成立? - 两个并行节点同时修改状态时,结果怎样合并? - 失败后重试当前节点、回到规划节点,还是直接结束? - 这条路径怎样被测试和观察? ## Claude Code Dynamic Workflows 已经是一张隐式图 Claude Code 的 Dynamic Workflow 很适合解释“workflow 和 graph 并不对立”。 按照当前的[官方文档](https://code.claude.com/docs/en/workflows),Claude 会先针对任务生成一段 JavaScript,之后由独立 Runtime 在后台执行。脚本中的 `agent()` 启动一个 subagent,`pipeline()` 按输入集合批量分发任务,中间结果保存在脚本变量中,而不是全部回到主对话上下文。 官方对这套设计的概括很直接:**把 plan 移进 code**。下一步运行什么,不再由主 Agent 每一轮临场判断,而由脚本控制。 下面是一段与官方示例同构的示意代码: ```javascript const files = await agent('找出所有路由文件'); const findings = await pipeline(files, (file) => agent(`审查 ${file} 的鉴权问题`)); return findings.filter(Boolean); ``` **图 1:Dynamic Workflow 中的 `agent()`、`pipeline()` 与变量形成 fan-out / fan-in 隐式图** ```mermaid flowchart LR S([开始:发现待审查文件]) --> A["发现文件"] A --> B1["审查 Agent 1"] A --> B2["审查 Agent 2"] A --> BN["审查 Agent N"] B1 --> C["汇总结果"] B2 --> C BN --> C --> E([结束:形成汇总结果]) ``` 图中没有 `add_node()` 或 `add_edge()`,但结构并没有消失:`agent()` 是节点,`pipeline()` 是动态 fan-out,等待所有结果是 fan-in,JavaScript 变量承担状态传递,`if` 和 `while` 则分别对应条件边与循环边。 **表 2:Claude Code Dynamic Workflow 元素到 Graph 元素的映射;依据 2026-07-30 核验的官方文档** | Dynamic Workflow 元素 | Graph 视角 | | --------------------- | --------------------------- | | `agent(prompt)` | Agent 节点 | | `pipeline(items, fn)` | 运行时 fan-out | | 等待批量结果 | fan-in / join | | `if` | 条件边 | | `for`、`while` | 循环边 | | JavaScript 变量 | 运行中的状态 | | 汇总 Agent | reducer 或 aggregation 节点 | | workflow script | code-first 的隐式图定义 | | workflow runtime | 图的执行器 | 这说明 Dynamic Workflow 可以承载 Graph engineering,但不等于一套通用、持久的 Graph 平台。当前文档列出的边界包括: - workflow 脚本不能直接访问文件系统或 Shell,实际操作由 subagent 完成; - 单次运行最多并发 16 个 Agent,累计最多 1000 个; - 除权限提示外,不支持普通用户在运行中输入; - 暂停后的恢复只在同一个 Claude Code session 内有效,退出后需要重新开始。 这里可以得到一个分析性结论: > **Claude Dynamic Workflow 是 code-first、agent-oriented 的隐式 Graph Runtime;它把编排从主上下文迁到了代码,但没有把状态 schema、跨 session 持久化和人工中断都提升为通用图原语。** 这不是说它“不完整”,而是适用目标不同。它优先解决的是本地大型任务的即时编排、上下文隔离和可复用脚本,不是运行几天甚至几个月的业务流程。 ## 图不一定由 Agent 决定,它有三种常见动态程度 把“Graph engineering 等于 Agent 自己生成图”当成定义,会漏掉大量最常用的系统。 ### 固定图:Agent 只在节点内部工作 **图 2:固定图中,Agent 只在预设节点内工作** ```mermaid flowchart LR B([开始:接收输入]) --> I["输入"] --> S["搜索"] --> A["Agent 分析"] --> V["验证"] --> O["输出"] --> E([结束:交付输出]) ``` 节点和边全部由代码确定。Agent 可以负责“分析”节点,但没有权力跳过验证,也不能临时调用图外节点。这种图和传统 workflow 很接近,价值主要来自显式状态、恢复和观察。 ### 动态路由图:Agent 在合法边中选一条 **图 3:分类 Agent 只能在工程师预定义的合法边中选择** ```mermaid flowchart LR S([开始:接收输入]) --> I["输入"] --> C{"分类 Agent"} C -- "普通" --> A["普通分析"] --> O["输出"] --> E([结束:交付输出]) C -- "高风险" --> R["高风险分析"] --> H["人工审批"] --> E ``` Agent 负责语义分类,但可以选择的路径仍由工程师预先定义。分类置信度过低时去哪里,也应当是一条明确的 fallback 边,而不是让 Agent 假装确定。 ### 动态子图:Agent 决定本次创建多少工作 **图 4:Planner 动态实例化调查任务,但外层汇总与验证骨架固定** ```mermaid flowchart LR S([开始:规划调查]) --> P["Planner"] P --> W1["调查任务 1"] P --> W2["调查任务 2"] P --> WN["调查任务 N"] W1 --> M["汇总"] W2 --> M WN --> M M --> V["验证"] --> O["输出"] --> E([结束:交付输出]) ``` 这里 N 和每个任务的内容由 Planner 根据输入决定,但外层仍有稳定骨架。LangGraph 使用 [`Send`](https://docs.langchain.com/oss/python/langgraph/graph-api#send) 支持这种运行时动态路由;Anthropic 的 orchestrator-workers workflow 也采用相同思想:子任务无法预先枚举,但“拆分—执行—汇总”的结构仍然已知。 **图 5:动态子图仍被固定元图包围,Planner 只能实例化允许出现的工作** ```mermaid flowchart LR S([开始:接收输入]) --> I["输入"] --> P["Planner"] P --> W1["Worker 1"] P --> W2["Worker 2"] P --> WN["Worker N"] W1 --> M["证据汇总"] W2 --> M WN --> M M --> V{"Validator"} V -- "通过" --> O["输出"] --> E([结束:交付输出]) V -- "证据不足" --> P V -- "高风险动作" --> H["人工审批"] --> E ``` 这张图真正重要的不是 N 个 Worker,而是 Planner 的外边界:最大 Worker 数量、允许调用的工具、状态写入范围、回到 Planner 的次数,以及高风险动作不能绕过人工审批。 ## 工程师不只提供约束,还要分配决策权 如果把工程师的工作概括成“提供约束”,容易只想到 `max_iterations: 3` 和“禁止删除生产数据”。这些当然重要,但 Graph engineering 至少还包含五类设计工作。 第一类是**任务分解**。哪些工作应该成为独立节点,哪些应该留在一个 Agent 的内部循环?节点过粗,状态和失败难以观察;节点过细,编排成本又会吞掉收益。 第二类是**状态契约**。节点读什么、写什么,原始证据是否只可追加,并行更新怎样去重,冲突结论能不能互相覆盖。LangGraph 用 State schema 和 reducer 处理这类问题;即使不用 LangGraph,也需要在自己的数据结构中回答同样的问题。 第三类是**决策权分配**。格式是否正确适合交给 schema;数值是否超过阈值适合交给代码;证据是否相关可以交给 Judge;是否允许生产写入必须由权限系统或人决定。不是每一条边都应该让模型投票。 第四类是**失败语义**。API 超时、Agent 返回非法 JSON、证据冲突和权限不足,虽然都叫“失败”,但恢复路径完全不同。Graph engineering 要把这些路径设计出来,而不是只写一个笼统的 retry。 第五类是**观察与评测**。最终报告通过不代表路由合理;某个节点正确也不代表整条路径成本可接受。需要分别观察节点输出、实际路径、状态变化、重试次数和最终任务成功率。 所以,约束只是其中一部分。更完整的说法是: > **工程师负责把业务目标变成一组可执行的职责、状态、决策权、失败语义和验收标准。** ## 约束不是靠灵感罗列,而是从图的五个切面推出来 面对一张新图,试图一次性想全所有异常几乎不可能。更实用的办法是先画最小图,再逐项检查 Node、Edge、State、Runtime 和 Completion。 约束又可以分为两种目的: - 防失控约束:避免越权、死循环、成本爆炸和不可逆破坏。 - 保质量约束:规定什么情况下才允许继续或完成。 前者回答“不能出什么事”,后者回答“什么才算做对”。只做前者,系统可能安全地输出废话;只做后者,系统可能为了得到好结果而无限重试。 **表 3:从 Graph 的五个切面盘点约束;问题栏可以直接用作设计评审清单** | 切面 | 需要回答的问题 | 约束示例 | | -------------- | ------------------------------------------------------ | -------------------------------------------------- | | Node | 职责是什么?输入前提是什么?能读写什么?怎样算完成? | 日志节点只写 `log_evidence`;缺少服务名则拒绝运行 | | Edge | 谁决定路由?进入条件是什么?失败后去哪?最多回来几次? | 覆盖率低于 0.8 返回补查;最多回到 Planner 两次 | | State | 数据从哪里来?谁能覆盖?并行结果怎样合并?是否持久化? | 原始证据只追加;冲突结论同时保留 | | Runtime / Tool | 超时、重试、并发、预算和权限怎样限制? | 最多 5 个并行 Agent;生产工具默认只读 | | Completion | 什么条件下才能结束?何时必须承认不确定? | 严重发现必须附证据;关键来源不可用时不得给确定结论 | ### 用失败模式反推遗漏的约束 完成五个切面的第一轮盘点后,再问一句:**每个节点最可能怎样失败?** 可以使用一个简化版 FMEA(Failure Mode and Effects Analysis,失效模式与影响分析)。不必追求复杂评分,先把失败、后果、发现方式和恢复路径连起来。 **表 4:代码审查 Agent Graph 的简化失败模式表;场景为多文件鉴权审计** | 对象 | 失败模式 | 后果 | 如何发现 | 预防约束 | 失败后处理 | | ---------- | ------------------------ | ------------------ | -------------------- | -------------------------- | ---------------------- | | Planner | 拆出过多文件任务 | Token 与耗时失控 | 监控任务数和预计成本 | 最多 20 个任务,超出时分批 | 合并低风险目录 | | 审查 Agent | 没读项目规则就下结论 | 大量误报 | 检查输出中的规则来源 | 输入必须包含生效的项目规则 | 退回重审 | | 汇总节点 | 用后一条结论覆盖前一条 | 冲突证据丢失 | 检查 evidence ID | 原始证据 append-only | 标记冲突并进入复核 | | Validator | 同模型投票形成一致性幻觉 | 错误结论被多数通过 | 对照测试或源码证据 | 高风险项必须有确定性证据 | 降级为“未验证” | | 整张图 | 验证失败后无限循环 | 资源耗尽 | iteration counter | 最多重规划两次 | 输出部分结果与不确定项 | 这张表会自然生成约束。比如发现“状态覆盖导致证据丢失”,约束就不该写成“汇总 Agent 应谨慎”,而应该把原始证据字段设计为只追加,并由 reducer 或存储层拒绝覆盖。 ## 一条约束要写成可执行的五元组 “Agent 应谨慎使用生产工具”不算可执行约束。它没有说明什么时候算生产操作,也没有说明违反后由谁阻断。 一条完整约束至少包含: ```text 适用范围 + 触发条件 + 必须行为 + 违反后处理 + 强制执行者 ``` 例如: ```yaml id: TOOL-007 scope: node: remediation-agent tool: rollback condition: environment: production required_behavior: - generate_dry_run_plan - obtain_human_approval on_violation: action: deny_tool_call enforcement: mechanism: runtime_permission_gate ``` 这条规则可以被测试:构造一个没有 approval ID 的生产回滚请求,确认 Runtime 会拒绝,而不是观察 Agent 有没有“记得谨慎”。 ### 约束必须放到真正能执行它的层 不是所有规则都应该写进 Prompt。Prompt 可以影响模型行为,却不是可靠的权限边界。 **表 5:约束类型与适合的执行层;越接近不可逆副作用,越不能只依赖模型遵守** | 约束类型 | 适合的执行层 | 示例 | | ---------------- | --------------------- | -------------------------- | | 操作方法与偏好 | Prompt、Skill | 先读取项目规则,再审查代码 | | 输出结构 | JSON Schema、类型系统 | 每条发现必须包含文件和证据 | | 数量、阈值、预算 | Runtime 计数器与代码 | 最多重规划两次 | | 状态合并 | reducer、数据库约束 | 原始证据只追加 | | 工具与资源权限 | IAM、沙箱、allowlist | 禁止 Agent 写生产数据库 | | 语义质量 | Judge、Verifier | 证据是否真正支持结论 | | 高风险决策 | Human gate | 发布、回滚、删除 | 这里有一条很朴素的原则: > **能用代码判断,就不要交给模型;能用权限系统阻断,就不要只在 Prompt 中提醒。** Agent 可以为当前任务生成局部软约束,例如“至少检查鉴权、输入校验和权限继承三条证据链”,也可以在证据不足时建议扩大搜索。但它不能自行提高预算、放宽工具权限,或取消原本要求的人工审批。执行者不能同时成为规则制定者和最终裁判。 ## 从最小约束集开始,再让失败轨迹补全它 约束不可能在第一次设计时全部列完。它通常来自四个地方: 1. 业务规则和不可违反的红线; 2. 系统架构与工具本身的限制; 3. 已知事故、失败案例和人工复盘; 4. Eval、红队测试和真实运行轨迹中暴露的新问题。 因此,Graph engineering 不是写完一份 YAML 就结束,而是一个持续循环: **图 6:失败轨迹推动约束、恢复边与回归用例持续演化** ```mermaid flowchart LR A["设计最小图与最小约束"] --> B["运行 Eval"] B --> C["观察失败路径"] C --> D["归类失败模式"] D --> E["增加约束或恢复边"] E --> F["增加回归用例"] F --> B ``` 每新增一条约束,最好同时新增一个能触发它的测试。否则它很容易从“系统保证”退化成“文档里写过”。 实际落地时,可以先给每个节点填一张八问卡: ```text 1. 这个节点只负责什么? 2. 运行前必须具备什么? 3. 可以读取什么? 4. 可以修改什么? 5. 输出 schema 是什么? 6. 最可能怎样失败? 7. 失败后走哪条边? 8. 谁来验证它成功? ``` 然后再为整张图补七个问题: ```text 1. 最多运行多久? 2. 最多循环和重规划几次? 3. Agent 可以创建或调用哪些节点? 4. 哪些动作必须人工批准? 5. 什么条件下算完成? 6. 什么条件下必须承认“不确定”? 7. 如何 checkpoint、恢复和审计? ``` 这 15 个问题不会覆盖所有生产异常,但足以把“凭感觉编排”推进到可测试的第一版。 ## 什么时候不要做 Graph engineering Graph engineering 最大的风险不是图不够复杂,而是图太复杂。 如果任务只有两三个固定步骤,一个 Agent 加清晰工具已经能稳定完成,或者失败后整体重跑的成本很低,那么显式设计大量节点、状态字段和恢复边只会增加调试负担。 [LangChain 在介绍 Graph engineering 时也明确提醒](https://www.langchain.com/blog/3-years-of-graph-engineering-with-langgraph),有些任务天生更开放,把它们强行塞进确定性路径反而不合适。[Anthropic 的建议](https://www.anthropic.com/engineering/building-effective-agents)更直接:从能工作的最简单方案开始,只有当复杂性可以证明改善结果时,才增加多步 Agent 系统。 可以用下面几个信号判断是否值得升级: - Prompt 已经塞满跨阶段的 `if/else` 和重复状态说明; - 不同失败类型需要不同恢复路径; - 有明显的并行收集、汇聚和交叉验证; - 任务需要暂停、恢复或人工审批; - 必须限制 Agent 的工具、预算和状态写入; - 只看最终答案已经无法定位质量问题; - 同一套编排需要长期复用和回归测试。 如果这些问题都不存在,普通 workflow 甚至单 Agent loop 通常已经够用。 ## 总结 Workflow 和 Graph engineering 不是新旧替代关系。很多 workflow 本来就是图,只是图结构藏在 JavaScript、Python、YAML 或主 Agent 的临场决策里。 Graph engineering 真正增加的,是把这些隐含问题变成显式工程对象:节点职责、合法边、状态契约、决策权、失败恢复、资源边界和验收标准。Agent 可以选择路径,也可以动态生成部分工作,但它的自主性应该发生在工程师定义的执行空间内。 所以,工程师并不只是“提供约束”。工程师要先决定哪些地方值得让模型判断,哪些地方必须由代码判断,哪些副作用必须由 Runtime 或人阻断。约束只是这套决策分工的可执行表达。 下一次设计 Agent workflow 时,不妨先别急着画一张更大的图。先给每个节点填完八问卡,再给整张图补上预算、停止、不确定性和人工审批。能回答这些问题,才算真正从“流程能跑”走到了“系统可控”。 ## 参考资料 - [LangChain:3 Years of Graph Engineering with LangGraph,2026-07-22](https://www.langchain.com/blog/3-years-of-graph-engineering-with-langgraph) - [LangGraph:Graph API overview](https://docs.langchain.com/oss/python/langgraph/graph-api) - [LangGraph:Persistence](https://docs.langchain.com/oss/python/langgraph/persistence) - [LangGraph:Interrupts](https://docs.langchain.com/oss/python/langgraph/interrupts) - [Anthropic:Building Effective AI Agents](https://www.anthropic.com/engineering/building-effective-agents) - [Claude Code:Orchestrate subagents at scale with dynamic workflows](https://code.claude.com/docs/en/workflows) - [OpenAI:A practical guide to building agents](https://openai.com/business/guides-and-resources/a-practical-guide-to-building-ai-agents/) https://www.mkshell.com/posts/dario-open-weights-safety-interest-china-bias/ https://www.mkshell.com/posts/dario-open-weights-safety-interest-china-bias.md 2026-07-29 # Dario 的开放权重立场:安全原则,还是 Anthropic 的利益叙事? 一家经营封闭模型的公司告诉你:高能力开放权重模型可能带来不可逆的安全风险,因此需要更严格的测试和限制。 这句话可能是真的,也可能刚好对这家公司的商业模式有利。更麻烦的是,两件事完全可以同时成立。 Dario Amodei 在 [《Our position on open-weights models》](https://www.anthropic.com/news/position-open-weights-models)中回应了外界对 Anthropic 的质疑。他否认主张全面禁止开放权重模型,却支持限制中国获得先进芯片、打击大规模违规蒸馏,并要求足够强的开放与封闭模型接受强制安全测试。 问题因此不只是“开放还是封闭”,而是三个更难的问题: - 文章中的安全判断有多少证据? - Anthropic 是否对自己和竞争者使用了不同标准? - 它对中国的描述是在分析风险,还是先有威胁预设,再选择证据? ## 先说结论:安全担忧成立,利益中立不成立 我的判断是: > Dario 提出的开放权重不可逆风险和按能力分级测试,方向上有合理性;但整篇文章不是中立的安全研究,而是一份将 AI 风险、美国国家安全和 Anthropic 商业利益绑在一起的政策文本。 这不等于已经证明 Dario 撒谎,也不等于 Anthropic 暗中做了自己禁止别人做的所有事情。当前证据能够支持的批评更具体: 1. Anthropic 把美国保持前沿 AI 优势当作安全前提,对中美军事和国家安全用途采用了明显不对称的风险基线。 2. 它把部分中国公司的可疑访问归因,进一步连接到中国政府支持和国家竞争,但公开证据不足以完整证明这条指挥链。 3. 它主张的评测、访问控制和受控部署,与 Anthropic 已经拥有的组织能力和封闭 API 模式高度兼容,存在把安全规范变成竞争壁垒的结构性风险。 4. 没有公开证据证明 Anthropic 秘密违规蒸馏竞争对手,也没有足够证据把这篇文章定性为操纵上市估值。 > **数据快照** > > - 最后核验:2026-07-29 > - Dario 原文:2026-07-27 发布,2026-07-28 编辑 > - Anthropic Responsible Scaling Policy:当前页面列出的最新版为 3.4,2026-07-08 生效 > - 证据范围:公开政策、服务条款、官方项目说明、行业公开信与英国 AISI 研究 > - 证据限制:无法从公开材料判断管理层的私人动机,也无法独立复核 Anthropic 未公开的蒸馏检测日志 ## 先别把开放权重和开源混成一锅 Dario 使用的是 **open-weight models**,即开放权重模型,而不是严格意义上的开源 AI。 开放权重通常意味着模型参数可以下载、本地运行和修改,但训练数据、数据处理流程、完整训练代码和许可证未必同时公开。[Open Source Initiative 的 Open Source AI Definition 1.0](https://opensource.org/ai/open-source-ai-definition)要求的不只是权重,还包括足以理解和修改系统的数据说明与训练代码。 这个区别很重要。文章讨论的主要问题是: > 当危险能力以可下载参数的形式扩散以后,部署者还能否持续监控、修补和撤回? 它并不是在完整讨论开源开发、数据透明、许可证自由和社区治理。 ## Dario 真正提出了什么 文章并没有要求把开放权重模型一刀切掉。相反,Dario 明确说,不具备危险能力的开放权重模型是一种公共利益;禁止美国企业使用中国开放模型,也阻止不了真正的恶意使用者,反而更像是在保护美国 AI 公司。 他的政策方案可以压缩成三条: 1. 限制中国获得先进芯片和芯片制造设备; 2. 打击使用虚假账户、代理服务和大规模调用进行的违规蒸馏; 3. 对达到危险能力门槛的所有模型强制测试,不区分开放或封闭、美国或中国。 这比“Anthropic 反对开源”的标签更克制。真正的分歧不在文章有没有承认开放模型的价值,而在于它怎样定义危险、怎样解释中国的技术进步,以及谁来制定测试门槛。 ## 开放权重的不可逆风险,不是 Anthropic 凭空发明的 开放权重确实带来封闭 API 很难提供的价值:私有部署、避免供应商锁定、更低成本、自由微调,以及必须接触权重才能开展的安全研究。由 NVIDIA、Meta、Microsoft、Mistral、Mozilla 等机构签署的[开放权重行业公开信](https://images.nvidia.com/pdf/Open-Weights-and-American-AI-Leadership.pdf)还强调,开放生态能够扩大竞争,让更多团队参与审计和防御。 但公开信也承认,权重发布后会离开原开发者控制,修改版本难以追踪和撤回。 英国 AI Security Institute 的[开放权重网络能力研究](https://www.aisi.gov.uk/blog/how-far-behind-the-frontier-are-leading-open-weight-models-on-cyber)给出了更直接的边界:开放模型可以被私有运行和修改,发布方却不能再统一封禁用户、升级分类器或撤回旧版本。研究同时发现,2026 年受测领先开放模型在网络能力上只落后此前沿封闭模型约 4 到 7 个月,但报告明确提醒,这个结果只覆盖其网络评测,不能外推到生物或其他风险。 因此,文章最扎实的结论是: > 同样的危险能力,以开放权重形式发布后更难补救。 这句话不等于“开放模型一定更危险”。开放也可能让更多防御者获得能力。攻击方还是防御方获益更多,要看具体领域、实际能力和部署方式,不能靠“开放天然安全”或“封闭天然安全”提前作答。 ## “没有美国芯片就无法超过美国”跨过了证据边界 Dario 把芯片管制描述成应对国家级 AI 竞争最直接的手段,这个方向不难理解:训练前沿模型需要大量计算资源,限制高性能芯片和制造设备会增加训练成本、集群建设难度与迭代时间。 问题出在文章使用了更绝对的推论:因为中国国内产能有限,加上 scaling laws,中国没有美国芯片就无法训练出比美国更强的模型。 文章链接的经典 [Scaling Laws for Neural Language Models](https://arxiv.org/abs/2001.08361)证明的是,在论文研究范围内,语言模型损失与模型规模、数据量和训练计算量呈稳定关系。它没有证明: - 有效计算只能由美国品牌芯片提供; - 算法、数据和训练效率无法部分抵消硬件差距; - 更多低性能芯片不能通过工程投入组成可用集群; - 蒸馏、强化学习、推理时计算和专用模型不会改变竞争路径; - 出口管制能够永久决定另一个国家的模型能力上限。 所以,更稳妥的结论应是: > 芯片管制能够提高成本、限制规模并延缓追赶,但 scaling laws 本身不能证明美国可以永久决定中国模型能力的上限。 文章把一个程度问题写成了接近必然性的判断。技术论文在这里提供了“算力重要”的证据,却没有提供“只有美国芯片才行”的证据。 ## 蒸馏争议里,技术、违约和国家行为被叠在了一起 蒸馏本身是一种普通训练方法:让较小模型学习较强模型的输出。Anthropic 自己也在[蒸馏攻击报告](https://www.anthropic.com/news/detecting-and-preventing-distillation-attacks)中承认,前沿实验室会合法地蒸馏自家模型。 Anthropic 反对的是另一类行为。它声称 DeepSeek、Moonshot 和 MiniMax 通过约 2.4 万个欺诈账户,与 Claude 产生超过 1600 万次交互,违反服务条款和区域限制,并针对推理、工具使用与编码能力进行提取。报告称归因依据包括 IP、请求元数据、基础设施特征和部分合作方信息。 这些材料能够证明什么? - 它们证明 Anthropic 公开提出了具体、可检验的公司级指控,而不是只说“我们怀疑有人蒸馏”。 - [Anthropic 当前商业条款](https://www.anthropic.com/legal/commercial-terms)确实禁止客户使用服务训练竞争模型,使用虚假账户和绕过区域限制也属于不同于“蒸馏技术本身”的行为。 - 但原始日志、归因规则和完整第三方复核没有公开,外部读者无法独立重放这次调查。 - 公司员工、代理基础设施或合作方的行为,也不能自动证明具体项目由中国政府下令、资助或协调。 Dario 的文章却从“中国公司进行了工业级违规蒸馏”,走到“这些行动得到一个试图超越美国的威权国家支持”。这一步可能有额外情报,但公开页面没有展示足以让读者复核的完整证据链。 因此,博客里最容易写错的一句话是:“中国公司靠偷 Claude 才追上美国。”现有公开材料最多支持: > Anthropic 指控三家中国实验室以违规访问方式大规模提取 Claude 能力;该指控细节具体,但关键证据仍主要掌握在 Anthropic 手中,不能据此解释中国模型进步的全部来源。 ## Dario 是否双标,要拆成五个不同问题 “双标”很有传播力,却容易把事实、推断和动机揉成一团。把问题拆开以后,证据强弱会清楚很多。 **表 1:截至 2026-07-29,五类双标质疑的公开证据与结论边界** | 质疑 | 可以核验的事实 | 当前能支持的判断 | | --------------------------------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------ | | Anthropic 暗中违规蒸馏竞品 | 本文核验的公开材料没有显示欺诈账户、绕过访问控制或未经授权提取竞品能力的证据 | 目前无充分证据,不能写成事实 | | Anthropic 限制竞争者利用 Claude | 商业条款禁止用 Claude 构建竞争产品或训练竞争模型 | 限制确实存在;是否合法、合理要按合同、访问方式和具体行为判断 | | Anthropic 一边反对军事 AI,一边服务美军 | Anthropic 从未普遍反对军事 AI;它公开为美国国家安全客户开发 Claude Gov | 不是“嘴上反军事、暗地做军事”,而是公开的阵营化安全立场 | | 强制评测会巩固 Anthropic 地位 | Anthropic 已有 RSP、评测、安全与政策团队,固定合规成本更容易由大公司承担 | 存在监管俘获和竞争壁垒风险,但不能据此证明垄断是唯一动机 | | 文章是为了 IPO 或估值 | 安全品牌和政策影响力客观上具有商业价值 | 缺少内部文件和因果证据,不能定性为估值操纵 | 这里最值得警惕的不是一条尚未证实的“偷着干”故事,而是一套稳定的自我正当化结构: > Anthropic 的扩张被解释成负责任地保持前沿;竞争者的扩张则更容易被解释成能力扩散、国家威胁或监管缺口。 这种结构不要求 Dario 主观上撒谎。一个人可以真诚相信 AI 风险,同时真诚相信“只有我的公司保持领先,才有能力解决风险”。问题在于,一旦公司利益和公共安全被这样绑定,外界就很难知道某项限制究竟减少了多少风险,又替既有企业增加了多少护城河。 ## 对华偏见不在民族仇视,而在风险基线不对称 把文章说成针对中国人或中国文化的仇恨文本,并不准确。它反对全面禁止中国开放模型,承认低风险开放权重的公共价值,也主张测试规则原则上覆盖不同国家的开放和封闭模型。 但文章存在很强的美国国家安全预设。Dario 直接把中国共产党称为最有能力的威权威胁,担心中国模型带来永久军事优势和深度社会压制。[Anthropic 当前政策页](https://www.anthropic.com/policy)也明确表示,最强模型应继续由美国及盟友民主国家掌握,并支持通过芯片出口管制和反蒸馏措施维持这种领先。 与此同时,Anthropic 并不反对 AI 进入军事和情报系统: - [Claude Gov](https://www.anthropic.com/news/claude-gov-models-for-u-s-national-security-customers)面向美国国家安全客户,覆盖战略规划、行动支持、情报分析和威胁评估,并降低模型处理机密材料时的拒答。 - Anthropic 与美国国防部的[两年期合作协议](https://www.anthropic.com/news/anthropic-and-the-department-of-defense-to-advance-responsible-ai-in-defense-operations)上限为 2 亿美元,包括在国防数据上开发原型,以及把 Claude 接入机密网络中的任务流程。 所以,Dario 的原则不是“军事和情报 AI 本身危险”,而更接近: > 民主国家在制度约束下发展军事 AI 是安全保障;威权国家获得同等级能力则是全球威胁。 这是一种可以公开辩论的政治立场,但不是中性的技术结论。文章没有对称分析美国军事和情报系统误用 AI、扩大监控或改变国际权力平衡的风险,也没有展示为什么同类能力在美国制度中就足以受到控制。 因此,更准确的定性是:文章没有充分证据表明其仇视中国人,却明显带有对中国政府和中国 AI 产业的威胁预设。它倾向把中国企业、国家产业政策、政治制度与军事竞争放进同一条因果链,同时把美国领先视为默认更安全的状态。 ## 安全监管怎样避免变成大公司的梯子 Dario 提出的“按能力测试,而不是按开闭源或国籍划线”值得保留。真正需要补上的,是防止规则只对后来者昂贵、只由前沿公司定义。 一个更对称的框架至少需要五层约束: 1. **同能力同规则。** 开放权重、封闭 API、美国模型和中国模型达到同一危险能力门槛时,接受相同等级的评测。 2. **模型与系统分开测。** 裸模型、工具调用、Agent 脚手架和安全措施移除后的能力不能混成一个分数。 3. **第三方参与制定门槛。** 评测机构、学术界、开放模型社区和中小企业共同参与,避免一家公司的内部 RSP 直接变成全行业准入证。 4. **给小团队低成本通道。** 政府或公共机构承担基础评测成本,并公开豁免、申诉、复测和规则失效机制。 5. **按行为治理蒸馏。** 区分自家模型压缩、授权使用、普通输出学习、虚假账户、绕过访问控制、合同违约与可证明的知识产权侵害。 开放权重也可以采用分阶段发布:先让独立评测机构和受约束研究者访问,再依据实际能力、可移除的安全措施和领域风险决定是否全面发布。这个方案未必适用于所有模型,但比“开放天然安全”和“权重永远不能放出”更接近可验证的治理。 ## 最终判断:不要猜动机,先检查标准是否对称 Dario 的文章最有价值的地方,是把开放权重风险从许可证争论拉回到能力、部署和不可逆性。它最薄弱的地方,则是把美国领先、中国威胁和 Anthropic 的受控部署模式写成近乎同一套安全结论。 判断这类公司政策文,不必先猜 CEO 心里到底想什么。可以先问四个更可验证的问题: - 同一种能力,落在开放和封闭模型上是否使用同一门槛? - 同一种军事或情报用途,发生在美国和中国时是否使用同一风险标准? - 同一种利用他人模型输出的行为,是否按访问方式、授权和证据判断? - 一项安全规则的固定成本,是否会把现有大公司的内部能力变成行业门票? 如果四个问题都能对称回答,安全主张才更接近公共规则;如果答案总是“Anthropic 可以继续做,但竞争者需要先被限制”,那么即使每一条风险都不是虚构,整套制度仍可能把安全变成一架只允许少数公司使用的梯子。 这也是本文对 Dario 最终的判断:他很可能真诚相信前沿 AI 风险,但他的风险观、美国国家安全立场和 Anthropic 商业利益高度同向。我们不需要把他想象成一个秘密策划所有事情的反派,也应该对这种三者同向保持足够警惕。 ## 参考资料 - [Dario Amodei:Our position on open-weights models,2026-07-27](https://www.anthropic.com/news/position-open-weights-models) - [Anthropic:Detecting and preventing distillation attacks,2026-02-23](https://www.anthropic.com/news/detecting-and-preventing-distillation-attacks) - [Anthropic Commercial Terms of Service,2025-06-17 生效](https://www.anthropic.com/legal/commercial-terms) - [Anthropic Responsible Scaling Policy,最后核验 2026-07-29](https://www.anthropic.com/responsible-scaling-policy) - [Anthropic AI policy,最后核验 2026-07-29](https://www.anthropic.com/policy) - [Anthropic:Claude Gov models for U.S. national security customers,2025-06-06](https://www.anthropic.com/news/claude-gov-models-for-u-s-national-security-customers) - [Anthropic 与美国国防部合作说明,2025-07-14](https://www.anthropic.com/news/anthropic-and-the-department-of-defense-to-advance-responsible-ai-in-defense-operations) - [Open Weights and American AI Leadership,2026-07-24](https://images.nvidia.com/pdf/Open-Weights-and-American-AI-Leadership.pdf) - [UK AISI:How Far Behind the Frontier are Leading Open Weight Models on Cyber?](https://www.aisi.gov.uk/blog/how-far-behind-the-frontier-are-leading-open-weight-models-on-cyber) - [Open Source AI Definition 1.0](https://opensource.org/ai/open-source-ai-definition) - [Scaling Laws for Neural Language Models,arXiv:2001.08361](https://arxiv.org/abs/2001.08361) https://www.mkshell.com/posts/2026-07-28-codex-session-handoff/ https://www.mkshell.com/posts/2026-07-28-codex-session-handoff.md 2026-07-28 # Codex session 太长怎么办?用交接文件把任务迁移到新 chat 我最近遇到一个问题:Codex 里的 session 越来越长,执行时也显得越来越重。但是任务本身还没有结束,旧 session 里又积累了不少决策和进度,直接新开一个似乎会“失忆”。 我的任务主要靠两样东西维持:一个是 `/goal`,另一个是本地的 `项目目标与进度基线.md`。问题就变成了:怎样换到一个更干净的 session,同时又不丢掉真正有用的信息? 先说结论: > 不要迁移完整聊天历史,而要迁移已经确认的事实、决策、进度、验证结果和下一步。 具体做法是:让旧 session 生成一份可复查的交接文件,更新项目进度基线,然后新建一个完全独立的 chat,由它从仓库文件恢复现场。这样迁移的是项目状态,而不是旧模型的全部思考过程。 ## 先分清 session、chat 和几种“继续” 为了方便,本文仍把一段长期对话称为 session。当前官方文档在桌面应用里主要使用 **chat**,CLI 的命令说明里仍能看到 **session**。它们在本文讨论的工作流里都指向同一个问题:Codex 下一轮工作要从哪里恢复上下文。 `resume`、`fork`、`/compact` 和新 chat 看起来都能解决“对话太长”,但它们保留的东西并不一样。 | 操作 | 对原聊天历史的处理 | 适合什么 | 不适合什么 | | ---------- | -------------------------------------- | ---------------------------- | -------------------------- | | `resume` | 重新加载原 session,保留原有历史 | 中断后继续同一条工作链 | 想摆脱旧 transcript 的负担 | | `fork` | 把当前或已有 chat 复制到一个新 chat | 保留现场,尝试另一种方案 | 只想迁移结论,不想复制历史 | | `/compact` | 用摘要替换较早的上下文,释放窗口 | 长任务中途临时整理上下文 | 彻底摆脱旧 session | | 新 chat | 不执行恢复或复制,从新的聊天上下文开始 | 通过仓库文件重新接手任务 | 依赖旧聊天里尚未落盘的信息 | | `/goal` | 把持久目标附着在当前 chat 上 | 有明确终点和验证循环的长任务 | 代替跨 chat 的项目状态文件 | 官方的 [Developer commands](https://learn.chatgpt.com/docs/developer-commands?surface=cli) 对这些边界写得很清楚:`resume` 会恢复保存的聊天并保留原历史,`fork` 会克隆当前聊天,`/compact` 则把早期内容替换成摘要。桌面应用里的 `/fork`、`/compact` 和 `/goal` 可以在 [Slash commands](https://learn.chatgpt.com/docs/reference/slash-commands) 中确认。 所以,如果目标只是“刚才断了,接着干”,用 `resume` 没问题;如果想保留现有上下文,探索另一条实现路径,用 `fork`;如果上下文只是暂时吃紧,可以先 `/compact`。但如果旧聊天已经长到想真正重新开始,新 chat 才是最干净的入口。 在桌面应用中,可以点击 **New chat**,也可以使用 `Cmd/Ctrl + N` 或 `Cmd/Ctrl + Shift + O`。当前快捷键以官方的 [桌面端命令文档](https://learn.chatgpt.com/docs/reference/commands) 为准。 ## 新 chat 为什么还能接上旧任务 因为一个长期项目真正需要继承的,并不是聊天的每一句话。 旧 session 里通常混着四类内容: 1. 已经确认的目标、约束和设计决策; 2. 当前代码、工作区和测试结果; 3. 搜索过程、失败尝试和临时推理; 4. 已经被后续结论取代的旧计划。 新 session 真正需要的是前两类,以及第三类中仍然会影响下一步的部分。至于几十次搜索命中、重复解释和已经放弃的方案,继续携带反而会让接手者分不清什么才是当前事实。 这和工程里的交接班很像。下一班不需要重看整晚的监控录像,但必须知道事故是否结束、排除了哪些原因、哪些操作已经验证,以及下一步应该观察什么。 因此,仓库需要承担一部分“外部记忆”。 ## 把长期状态分成四层 我会把长期任务的状态拆成下面四层: | 文件 | 保存什么 | 更新频率 | | ------------------------------- | -------------------------------------------- | ------------------- | | `AGENTS.md` | 仓库级稳定规则、命令、安全边界和验收要求 | 规则真正变化时 | | `项目目标与进度基线.md` | 当前目标、checkpoint、已完成事项、待办和风险 | 每个重要 checkpoint | | `docs/agent-handoffs/LATEST.md` | 上一个 session 留给下一个 session 的压缩交接 | 每次迁移 session 前 | | `PLANS.md` | 阶段计划、里程碑和较长的实施拆解 | 计划变化时 | 这里最容易犯的错误,是把所有内容都塞进 `AGENTS.md`。它适合保存稳定规则,不适合记录“今天改到哪了”。官方的 [AGENTS.md 文档](https://learn.chatgpt.com/docs/agent-configuration/agents-md) 也把它定位为持久的项目指令,并支持从仓库根目录到子目录逐层覆盖。 换句话说: - `AGENTS.md` 回答“在这个仓库里应该怎样工作”; - 进度基线回答“这个项目现在走到哪里”; - handoff 回答“上一个 session 刚做完什么、接下来先看什么”; - `PLANS.md` 回答“这一阶段准备怎样完成”。 职责分开之后,新 session 不需要猜哪段信息是否过期。 ## 第一步:让旧 session 只做交接 准备迁移时,不要再让旧 session 顺手多修一个问题。它最后的任务应该是核对现场和生成交接。 下面这段提示词可以直接使用: ```markdown 请只做 session 交接,不要继续实现业务功能。 目标:把当前 session 中仍然有效的上下文压缩并落到仓库文件, 让一个不读取旧聊天历史的新 Codex chat 可以继续工作。 请先读取并核对: - AGENTS.md,以及当前目录下可能生效的嵌套 AGENTS.md - 项目目标与进度基线.md - PLANS.md(如果存在) - 本轮实际修改过的相关文件 - git status --short - git branch --show-current 然后: 1. 创建或更新 docs/agent-handoffs/LATEST.md; 2. 更新 项目目标与进度基线.md 中的当前目标和 checkpoint; 3. 不要修改业务代码。 交接文件必须包含: - 本轮 session 的目标; - 已确认的关键设计决策及理由; - 已修改文件和当前工作区状态; - 已完成事项与未完成事项; - 已运行的验证命令及结果; - 尚未运行的验证; - 阻塞、风险和证据缺口; - 已经否定、不要重复尝试的方案; - 下一位 agent 启动后的第一步; - 需要优先阅读的文件。 完成后只汇报更新了哪些状态文件,不要继续开发。 ``` 这份提示词有两个关键点。 第一,交接前先读 Git 状态和实际文件,而不是只凭聊天记忆写总结。聊天里可能还说着“准备修改”,但工作区中可能已经改完,也可能根本没有落盘。 第二,要求记录“不要重复做的事”。一个失败方案如果只存在于旧聊天里,新 session 很可能会再走一遍。 ## 第二步:检查交接是否能独立成立 交接文件不是“旧 session 的总结作文”,而是新 session 的启动输入。至少要能回答下面几个问题: - 当前唯一目标是什么? - 到什么状态才算完成? - 哪些结论已经被代码、命令或文档验证? - 工作区有没有未提交修改? - 下一步应该读哪个文件、执行哪个命令? - 哪些操作有风险,必须先停下来确认? 如果 handoff 里写的是“继续完成之前讨论的方案”,这份交接就失败了。新 session 没有“之前”。 更好的写法是: ```markdown ## 当前 checkpoint 认证中间件已经切换到新的 token 校验入口,但刷新流程尚未迁移。 ## 已验证 - `pnpm test auth` 通过; - 登录和过期 token 用例通过; - 尚未运行完整构建。 ## 下一步 先阅读 `src/auth/refresh.ts` 和对应测试,确认刷新 token 是否仍调用旧入口。 完成修改后运行 `pnpm test auth` 与 `pnpm build`。 ``` 即使完全看不到旧对话,这段信息也能让下一位 agent 开始工作。 ## 第三步:用新 chat 恢复现场 确认交接已经落盘后,再创建新 chat。不要 `resume`,也不要从旧聊天 `fork`,因为这两种操作的目的本来就是保留原现场。 新 chat 的第一条消息可以这样写: ```markdown 请接手当前仓库任务,但不要依赖任何旧聊天历史。 开始工作前必须读取: 1. AGENTS.md,以及当前任务目录下可能生效的嵌套 AGENTS.md; 2. 项目目标与进度基线.md; 3. docs/agent-handoffs/LATEST.md; 4. PLANS.md(如果存在)。 然后执行只读检查: - git status --short - git branch --show-current - 阅读 handoff 指向的相关文件 先总结你恢复出的: - 当前目标和完成条件; - 已完成事项; - 当前 checkpoint; - 未完成事项与下一步; - 工作区状态; - 风险、阻塞和未运行的验证。 如果文件之间冲突,不要自行拼凑结论。指出冲突、文件日期和 Git 证据, 再给出你建议采用的基线。 多步任务先进入 plan mode。确认计划前不要修改业务文件。 ``` 这里故意要求先做只读检查。新 session 的第一反应不应该是“根据摘要继续写代码”,而是确认摘要和仓库当前状态仍然一致。 ## 第四步:在新 chat 重新建立 goal `/goal` 很适合有明确终点、验证循环和停止条件的长期任务。官方的 [Goal mode 指南](https://learn.chatgpt.com/use-cases/follow-goals) 建议在目标里说明要先读取的文件、怎样验证进度,以及什么时候停止。 但 `/goal` 是当前 chat 的持久目标,不是整个仓库的项目数据库。换了新 chat,先恢复文件状态,再重新设置目标: ```markdown /goal 基于 `项目目标与进度基线.md` 和 `docs/agent-handoffs/LATEST.md` 继续推进当前任务。 每个 checkpoint 后: 1. 更新进度基线; 2. 运行文件中约定的验证命令; 3. 记录通过、失败和未运行的检查; 4. 遇到目标冲突、破坏性操作或缺少关键输入时暂停。 当进度基线中的完成条件全部满足,并且要求的验证通过后停止。 ``` 如果目标本身还没有收敛,可以先用 `/plan` 把范围和验收条件谈清楚,再设置 `/goal`。当前桌面应用的 Slash commands 文档也把这条路径作为推荐用法。 ## 把迁移变成固定循环 一次迁移不难,难的是半年后仍然知道哪个文件可信。可以把日常工作固定成下面的循环: **图 1:从读取规则和进度基线到新 chat 恢复的迁移循环** ```mermaid flowchart TD A["读取规则与进度基线"] --> B["执行当前 checkpoint"] B --> C["运行验证并记录结果"] C --> D["更新进度基线"] D --> E{"需要换 chat?"} E -- "否" --> B E -- "是" --> F["生成 handoff"] F --> G["新 chat 重新读取并核对仓库"] G --> A ``` 每个 checkpoint 不必写成长篇日报。有效的进度记录只需要说明:做了什么、证据是什么、还剩什么、下一步是什么。 `LATEST.md` 也不必无限增长。它表示最近一次交接,历史交接可以按日期归档,例如: ```text docs/ agent-handoffs/ LATEST.md 2026-07-28-codex-session-handoff.md ``` 如果项目没有长期追溯需求,只保留 `LATEST.md` 也可以。关键不是文件数量,而是新 session 能找到唯一、明确且足够新的入口。 ## 什么时候不需要这样做 这套方法不是每次对话都要执行。 如果只是关掉终端后继续昨天的同一个任务,`resume` 更直接;如果要保留当前上下文,验证一个替代设计,`fork` 更合适;如果聊天仍然可靠,只是上下文窗口暂时紧张,先 `/compact` 就够了。 只有当你明确想摆脱旧 transcript,同时项目又不能从头开始时,才值得做“交接落盘 → 新 chat 恢复”。 还要注意:本文没有证明新 chat 一定能带来多少 CPU、内存或响应时间改善。它能确定减少的是继承的聊天历史,并把长期状态变成可检查的仓库文件。至于实际性能,还会受到模型、工具调用、仓库规模和当前应用版本影响。 ## 总结 长 session 的问题,不只是上下文太多,更重要的是项目状态和聊天历史混在了一起。 `resume` 负责继续原 session,`fork` 负责复制现场探索分支,`/compact` 负责在原聊天里压缩上下文。它们都有用,但都不是“只带结论重新开始”。 真正可持续的做法,是把稳定规则放进 `AGENTS.md`,把当前目标和 checkpoint 放进进度基线,把最近一次交接放进 `LATEST.md`。然后开一个新 chat,让它先读文件、核对 Git,再重新建立 `/goal`。 聊天可以结束,项目状态不能只活在聊天里。 https://www.mkshell.com/posts/clawbench-browser-agent-benchmark-judge/ https://www.mkshell.com/posts/clawbench-browser-agent-benchmark-judge.md 2026-07-27 2026-08-01 # ClawBench 测到了什么?从真实网页任务到 Judge 可靠性审计 一个浏览器智能体打开 DoorDash,选好商品,进入结账页,然后告诉你:“购物车已经准备好了。” 任务完成了吗? 站在聊天窗口里看,好像完成了;站在服务器一侧看,却可能连有效的购物车 ID 都没有。智能体自己说“完成”,页面看起来“差不多”,都不能证明最后那次提交是正确的。 这正是 [ClawBench](https://arxiv.org/pdf/2604.08523) 想测量的问题:不是模型能不能解释怎样订餐、投简历或创建会议,而是它能不能在真实网站上持续执行,直到产生正确的最终请求。 不过,当一个基准不再用固定答案或测试脚本判分,而是让另一个 Agent 阅读执行轨迹并给出 PASS/FAIL 时,问题就多了一层: > 我们不但要问被评智能体是否可靠,还要问负责判分的 Judge 是否可靠。 本文先看 ClawBench 到底测到了什么,再沿着评分链路检查 Judge 收到了哪些证据、怎样做判断,以及论文给出的可靠性数据究竟能支持多强的结论。 先说结论: - ClawBench 测量的是“理解任务—读取用户数据—操作动态网站—正确提交—验证结果”的端到端闭环,不是孤立的模型能力。 - 论文中的 V1 Agent-as-Judge 有完整轨迹和人工参考轨迹可供审计,设计上比只看最终回答扎实得多。 - 论文确实做了 prompt 校准和人工复核,但只报告 raw agreement。按表 2 的数据反推,Sonnet 4.6 组表现很好,GPT-5.4 组却出现 Judge 与人工正例完全不重合的情况。 - 当前 V2 排行榜已经改用“请求拦截 + payload Judge”的两阶段评分。V1 的可靠性数据不能自动替 V2 Judge 背书。 > **数据快照** > > - 最后核验:2026-08-01; > - 论文版本:arXiv v2,2026-07-20 修订; > - 论文实验:V1 任务集,OpenClaw harness,153 道任务; > - 官网排行榜:V2 任务集,Hermes harness,快照日期 2026-05-20。 ## 先把两个 ClawBench 版本分开 截至 2026 年 8 月 1 日,[论文最新版](https://arxiv.org/abs/2604.08523)仍是 7 月 20 日提交的 v2。但这里的“论文 v2”和“任务集 V2”不是一回事。 论文的主实验仍然使用最初的 V1 任务集: - 153 道任务; - 144 个真实平台; - 15 个细分类别、8 个高层领域; - 统一使用 OpenClaw harness; - 使用 Claude Sonnet 4.6 驱动的 Agent-as-Judge 阅读完整轨迹。 而[当前官网](https://claw-bench.com/)默认展示的是另一套 V2 任务和排行榜: - 130 道任务; - 63 个真实平台; - 榜单快照日期为 2026 年 5 月 20 日; - 先检查最终请求的 URL 和 HTTP method,再用 DeepSeek V4 Pro 判断 payload。 两边不仅任务不同,harness 和 Judge 也不同。所以,论文中的 Sonnet 4.6 得到 33.3%,官网 V2 中 Opus 4.7 得到 44.6%,不能直接解释为“模型提升了 11.3 个百分点”。 后文把论文实验称为 **V1/OpenClaw**,把当前排行榜称为 **V2/Hermes**。如果不先固定这个边界,后面的数字很容易变成一锅排行榜粥。 ## 一道任务是怎样变成一个分数的 ClawBench 的关键设计,是让智能体在真实生产网站上执行前面的所有步骤,只在最后会产生真实副作用的请求上踩刹车。 **图 1:ClawBench 从任务输入、终态请求拦截到 Judge 判分的完整链路** ```mermaid flowchart LR S([开始:接收 benchmark 任务]) --> A["任务 instruction
用户资料与附件"] A --> B["浏览器 Agent
搜索、登录、填写、选择"] B --> C["五层轨迹
视频、截图、HTTP、动作、消息"] B --> D{"最终请求匹配
任务的 endpoint?"} D -- 否 --> F["未完成或进入人工轨迹判定"] --> J D -- 是 --> E["拦截 URL、method、payload
不发送到生产服务器"] C --> J["Judge"] E --> J H["人工参考轨迹
目标终态与字段绑定"] --> J J --> K{"满足任务要求?"} K -- 是 --> P([结束:PASS]) K -- 否 --> Q([结束:FAIL]) ``` 论文介绍的安全层会监控浏览器发出的网络请求。人工标注者先完整执行一次任务,找出真正会提交订单、申请、邮件或预约的 terminal request。智能体运行时,系统允许页面加载、搜索、登录和填表正常发生;一旦匹配最终请求,就保存 URL、method 和 payload,并在请求到达生产服务器前阻断它。[论文报告](https://arxiv.org/pdf/2604.08523),在 153 条人工参考轨迹中,拦截器捕获了全部已标注终态请求,并且没有误拦截正常导航流量。 因此,“没有出现支付成功页”不一定是失败。只要智能体填对了内容并发起正确的付款请求,拦截器就应该让页面停在成功之前。 每次执行同时记录五层证据: 1. 完整会话视频; 2. 每一步的页面截图; 3. HTTP 请求; 4. 点击、输入、滚动等底层浏览器动作; 5. Agent 消息、推理和工具调用。 人工参考运行也使用相同的记录方式,只是没有 Agent 消息。Judge 比较的是实际执行结果与人工目标终态,而不是智能体最后一句自我总结。 ## ClawBench 实际测量的四段能力链 ClawBench 官方按生活领域报告成绩,并没有分别输出“规划分”“感知分”或“记忆分”。下面的四组能力,是根据任务和失败轨迹做的分析性拆解,不是官方的四个子指标。 ### 1. 从用户数据到网页字段 不少任务的答案不在 instruction 里,而在本地文件中。智能体要先读取文件,再把里面的数据准确映射到网页。 [Greenhouse #86](https://github.com/TIGER-AI-Lab/ClawBench/blob/main/test-cases/v1/086-job-search-hr-cv-autofill-greenhouse-meta/task.json)要求从 `resume.pdf` 提取信息,填写 CodePath 的 Senior Software Engineer 申请。这里不只是上传简历,还要处理姓名、工作经历、联系方式和职位表单之间的字段对应。 [Overleaf #215](https://github.com/TIGER-AI-Lab/ClawBench/blob/main/test-cases/v1/215-academia-research-paper-tables-overleaf/task.json)则要求读取 `raw_results.json`,在 Overleaf 创建包含 5 个模型、4 个指标、Average 和 Delta 行的 `booktabs` 表格,并保证 LaTeX 可以编译。 这两题表面上一个是投简历,一个是写论文,底层要求却很接近: - 找到正确的输入文件; - 抽取原始数据,而不是凭印象补值; - 保持跨页面、跨字段的一致性; - 把文件中的结构转化为网站中的状态修改。 论文在失败分析中发现过智能体填入源材料中不存在的电话号码。这不是“差一点”,而是数据保真已经断掉了。 ### 2. 把长任务拆开,同时记住中间状态 [Trello #142](https://github.com/TIGER-AI-Lab/ClawBench/blob/main/test-cases/v1/142-office-secretary-tasks-collab-trello/task.json)要求创建名为 “Q3 Sprint Planning” 的看板,再根据附件建立 3 个列表和 5 张卡片。附件还包含任务名、负责人、截止日期和优先级。 [Doodle #137](https://github.com/TIGER-AI-Lab/ClawBench/blob/main/test-cases/v1/137-office-secretary-tasks-calendar-doodle/task.json)要求创建 5 人会议投票,设置 4 个候选时段、每段 60 分钟,并把邀请链接发送给另外 4 人。 这些任务不是一次点击,而是一组相互依赖的状态变化。创建卡片前要先有看板和列表,发邀请前要先生成正确的投票。页面每跳转一次,智能体都要记得哪些对象已经创建、哪些字段还没有填。 论文给出的 Doodle 对比轨迹很说明问题:Qwen 3.5 发现逐周切换日历效率太低后,改用月视图继续推进;GPT-5.4 转向复杂的 JavaScript 绕过方案后退出;Gemini 3 Flash 则重复相同的无效操作直到超时。三者未必不理解“创建会议投票”是什么意思,差别出在执行过程能否保持单调推进。 ### 3. 在动态页面上精确行动 真实网站不会为 benchmark 保持静止。Cookie 弹窗、异步 DOM、登录重定向、级联表单、日期控件和反自动化页面都会改变后续路径。 例如,Greenhouse 的字段会随职位变化;Doodle 要操作日历网格;Overleaf 既要编辑文本,又要观察编译结果。表单校验失败以后,智能体还要判断是日期格式、必填字段还是前置选择出了问题。 这部分能力不能简单归结为“视觉识别”。它至少包括: - 看懂当前页面状态; - 把目标定位到可执行的点击或输入; - 等待异步更新真正完成; - 页面变化后放弃失效的元素引用; - 发现局部错误时只修复错误字段,而不是从头重来。 论文记录过 Airbnb 上 1204 个动作、549 条消息仍未完成的轨迹。动作很多不代表更努力,有时只是页面状态和 Agent 内部状态已经脱节。 ### 4. 走完最后一步,也知道何时该换策略 [Uber Eats #1](https://github.com/TIGER-AI-Lab/ClawBench/blob/main/test-cases/v1/001-daily-life-food-uber-eats/task.json)要求订一份 Pad Thai,送到用户家庭地址,并备注 “no peanuts”。选到菜品、加入购物车,只完成了前半段;地址、备注、结账和最终 Place Order 都属于任务。 论文把执行深度分成从“没有进展”到“Judge 判定通过”的七个阶段。六个模型的大量失败集中在 S5:已经到达最终确认,却没有发出会改变服务器状态的请求。 不同模型还会以不同方式卡住: - Gemini 3.1 Flash Lite 的 153 次运行中,131 次以 `agent_idle` 结束; - GLM-5 有 69 次撞上 30 分钟上限; - GPT-5.4 有 85 次以 `agent_exited` 结束。 所以低成功率背后至少有三种问题:过早退出、无效循环和最后一步犹豫。把它们都概括成“推理能力不足”,对改进 Agent 没有太大帮助。 ## 同一套 OpenClaw 下,模型表现怎样 论文把八个模型放进相同的 OpenClaw harness,使用相同浏览器动作接口、30 分钟上限和 Agent-as-Judge。下表是这组可横向比较的 V1 结果: **表 1:V1/OpenClaw,153 道任务,30 分钟上限,Claude Sonnet 4.6 Agent-as-Judge;成功率与成本为论文报告值** | 模型 | 成功率 | 平均成本/题 | 主要观察 | | --------------------- | --------: | ----------: | -------------------------- | | Claude Sonnet 4.6 | **33.3%** | $7.51 | 准确率最高,但成本明显更高 | | Qwen 3.5 | **26.1%** | $1.02 | 动作和 token 效率较好 | | GLM-5 | **24.2%** | $0.64 | 成本低,但大量运行超时 | | Gemini 3 Flash | 19.0% | $1.55 | 旅行类较强,失败轨迹偏长 | | Claude Haiku 4.5 | 18.3% | $1.68 | 开发类任务得分最高 | | Gemini 3.1 Pro | 9.8% | $3.34 | 成本不低,整体成功率偏低 | | GPT-5.4 | 6.5% | $1.91 | 工具调用少主要来自过早退出 | | Gemini 3.1 Flash Lite | 3.3% | $0.11 | 大量运行进入 idle | 数据来自论文的[表 3 和图 5](https://arxiv.org/pdf/2604.08523)。需要特别注意三点。 第一,Sonnet 4.6 的 33.3% 已经是最高分。这不是少数离群难题拖低了平均数:153 道题中有 68 道没有任何一个模型完成,没有一道被八个模型全部完成。 第二,领域排名并不一致。Sonnet 在日常、学术和社交任务中领先,GLM-5 在工作类任务中最好,Haiku 4.5 在开发类任务中最好。这说明 ClawBench 不是单一“会不会点网页”的测量。 第三,GPT-5.4 的 6.5% 不能外推成模型的通用能力排名。论文测到的是: > GPT-5.4 × 当时的 OpenClaw harness × 固定提示词 × 浏览器工具接口 × 网站状态 × Judge。 平均 13 次工具调用和 85 次主动退出更像是代理栈中的终止策略问题,而不是“用很少动作高效完成任务”。官网 V1/Hermes 页面上,同名模型又得到 25.5%,也从侧面说明 harness 会显著改变结果。 ## V1 Judge 到底看什么 论文中的 Judge 不是一个比较最终文本的 LLM 分类器,而是 Claude Sonnet 4.6 驱动的 evaluator Agent。 按照论文和公开的 [`eval/agentic_eval.md`](https://github.com/TIGER-AI-Lab/ClawBench/blob/main/eval/agentic_eval.md),它可以读取: - 任务 instruction 和 task-card 约束; - 人工参考轨迹; - Agent 的视频、截图、HTTP 流量、浏览器动作和消息; - `run-meta.json` 中的任务与终止信息; - `interception.json` 中的最终 URL、method 和 payload。 人工轨迹提供的是目标终态和具体字段绑定,但不是要求 Agent 逐步复制人工点击。只要走另一条路径达到语义等价的终态,也可以通过。 公开 rubric 还明确处理了几个容易误判的边界: - 只加入购物车,没有填结账表单并点击提交,FAIL; - 使用虚拟支付信息并尝试付款,即使真实支付被拒,也可以 PASS; - 正确的最终请求被拦截,且此前字段都正确,PASS; - 卡在电话验证码,但此前步骤完整,可以 PASS; - 遇到 CAPTCHA 必须尝试,未解决仍是 FAIL; - Agent 自称完成,不能覆盖页面和网络证据。 公开的 [`eval/README.md`](https://github.com/TIGER-AI-Lab/ClawBench/blob/main/eval/README.md)还描述了批量评测方式:16 个 evaluator subagent 并行处理任务,3 个 supervisor agent 检查一致性,最后合并为 CSV 和 JSON。 这个设计的优点很直接:Judge 能沿着证据回查,而不是相信 Agent 的自我陈述。它的代价也同样直接:最终 PASS/FAIL 依赖另一个模型对大量异构证据的理解。 ## 论文怎样证明 Judge 可靠 论文给了两类验证。 第一类是 prompt calibration。附录 D.3 描述了一个迭代过程:准备带人工标签的 held-out 轨迹,用当前 prompt 判分,收集分歧,针对部分完成、拦截提交和替代路径等边界修改规则,然后重新跑完整校准集,避免修好一个案例又破坏另一个案例。 第二类是人工一致率。论文表 2 报告: **表 2:V1/OpenClaw,每组 153 道任务;Judge 成功率、人工成功率与 raw agreement 为论文报告值** | 被评模型 | Agent-as-Judge 成功率 | 人工成功率 | Raw agreement | | ---------- | --------------------: | ---------: | ------------: | | Sonnet 4.6 | 33.33% | 34.64% | 93.46% | | GPT-5.4 | 6.54% | 8.50% | 84.97% | 93.46% 和 84.97% 看起来都不低。但 raw agreement 只是两边标签相同的比例。在成功率很低时,双方一起判 FAIL 就能积累大量“一致”。 这时,我们需要把百分比还原成混淆矩阵。 ## 从表 2 反推混淆矩阵 下面是本文根据论文表 2 计算的结果,不是作者直接报告的指标。 每组都有 153 道题。表中的百分比恰好对应整数: - Sonnet:Judge 判 51 道 PASS,人工判 53 道 PASS,共有 143 道一致; - GPT-5.4:Judge 判 10 道 PASS,人工判 13 道 PASS,共有 130 道一致。 设 Judge 通过数为 `J`,人工通过数为 `H`,一致数为 `A`,总数为 `N`。因为: ```text A = TP + TN = TP + (N - J - H + TP) ``` 所以: ```text TP = (A - N + J + H) / 2 ``` 其余三格也就唯一确定了: **表 3:根据论文表 2 反推的混淆矩阵;TP、FP、FN、TN 与 Cohen's κ 为本文计算值** | 模型 | TP | FP | FN | TN | Raw agreement | 全判 FAIL 基线 | Cohen's κ | | ---------- | --: | --: | --: | --: | ------------: | -------------: | --------: | | Sonnet 4.6 | 47 | 4 | 6 | 96 | 93.46% | 65.36% | 约 0.854 | | GPT-5.4 | 0 | 10 | 13 | 130 | 84.97% | **91.50%** | 约 −0.080 | Sonnet 组的结果确实很好。以人工标签为准,它找到了 53 个正例中的 47 个,precision 约为 92.2%,recall 约为 88.7%。即使校正类别比例,κ 仍然约为 0.854。 GPT-5.4 组则完全不同: - 人工认为成功的 13 道题,Judge 全部判为 FAIL; - Judge 判为 PASS 的 10 道题,人工全部判为 FAIL; - 84.97% 的一致全部来自双方共同判 FAIL 的 130 道题; - 一个不看轨迹、永远输出 FAIL 的分类器,反而能得到 91.50% 的 agreement; - κ 约为 −0.080,校正类别分布后不比偶然一致更好。 因此,论文可以说“在 GPT-5.4 轨迹上 raw agreement 为 84.97%”,却不能把这个数字直接改写成“Judge 在 GPT-5.4 上有 84.97% 的可靠性”。 这不是说 Judge 一定不可用,而是说当前证据只能支持更窄的结论:它在 Sonnet 组上与人工高度一致,在 GPT-5.4 组上共同识别了很多失败,但没有正确识别任何人工正例。 ## 可靠性证据还缺什么 论文已经提供了比很多 LLM Judge 更丰富的审计材料,但要证明它可以稳定充当排行榜 ground truth,还缺少几个关键环节: - 完整混淆矩阵和正例 precision、recall; - balanced accuracy 或 Cohen's κ 等抗类别不平衡指标; - 同一 Judge 多次运行的判分稳定性; - 不同 Judge 模型、不同 prompt 版本之间的一致性; - 多名人工评审者的 inter-rater agreement; - 人工争议案例的裁决方法; - 校准集规模,以及它与最终人工审计集是否严格隔离。 论文正文还说 Appendix B.1 包含扩展的四模型审计,但在 2026 年 7 月 20 日的公开 v2 中,B.1 展示的是拦截器安全审计,随后直接进入 B.2,没有列出另外两个模型的具体一致率。这可能是论文修订时遗漏了表格,也可能数据发布在其他位置;在看到具体结果前,不应把“四模型审计”当成已经公开可复查的证据。 另一个容易混淆的点是:拦截器在 153 条人工轨迹上达到 100% 命中,证明的是终态请求捕获机制,而不是 LLM Judge 的语义判断。前者回答“有没有拦住预先标注的请求”,后者回答“这条轨迹是否满足自然语言任务”,不能混成一个可靠性结论。 ## V2 已经换了另一套 Judge 当前 V2/Hermes 排行榜使用两阶段评分,[`eval/scoring.md`](https://github.com/TIGER-AI-Lab/ClawBench/blob/main/eval/scoring.md)给出了实现边界: 1. **Intercepted**:最终请求的 URL 是否匹配任务正则,HTTP method 是否一致; 2. **Reward**:DeepSeek V4 Pro 在温度 0 下读取 instruction 和被拦截请求,判断 body 是否满足用户要求。 V2 Judge 默认不看截图、视频和完整人工参考轨迹。输入是 instruction,以及压缩后的 URL、method、body;headers 和 body 总长度还会截断到 4 KB 左右。 这套设计便宜、可复现,也能区分“到达正确 endpoint”和“payload 真的正确”。例如,当前榜单中 OpenRouter Owl Alpha 的 Intercepted 是 14.6%,Reward 却是 0%,说明触发某类终态接口并不等于提交内容满足要求。 但它与 V1 Judge 回答的不是完全相同的问题: **表 4:论文 V1/OpenClaw 与官网 V2/Hermes 的 Judge 输入、模型与判定对象对比** | 维度 | 论文 V1 Agent-as-Judge | 当前 V2 payload Judge | | -------------------- | -------------------------------------- | --------------------------------- | | Judge 模型 | Claude Sonnet 4.6 | DeepSeek V4 Pro | | 主要输入 | 任务、人工轨迹、Agent 全轨迹、最终请求 | 任务、URL、method、body | | 是否看截图和动作 | 是 | 否 | | 是否依赖人工参考轨迹 | 是 | 默认否 | | 判定对象 | 整体任务是否完成 | 最终 payload 是否满足 instruction | 所以,论文的 84.97%–93.46% raw agreement 不能转移成 V2 Judge 的可靠性证明。V2 需要自己的人工混淆矩阵和稳定性实验。 ## 应该怎样阅读一个 ClawBench 分数 ClawBench 最有价值的地方,不是产生了一个新的模型排行榜,而是把“看起来会操作网页”和“真正完成真实任务”之间的差距暴露出来了。 但一个 ClawBench 分数实际测量的是: ```text 模型 × harness × 浏览器观察 × 动作接口 × prompt × Judge × 当时的网站状态 ``` 阅读排行榜时,可以按下面的顺序检查: 1. 先确认是 V1 还是 V2,任务数和平台是否相同; 2. 再确认使用 OpenClaw、Hermes、Codex 还是其他 harness; 3. 确认主指标是 trajectory PASS、Intercepted、Reward 还是 strict Reward; 4. 检查 Judge 模型、prompt 和输入证据是否相同; 5. 最后再讨论模型排名、成本和领域强弱。 如果其中任何一项发生变化,分数差异都不能简单归因于模型。 ## 总结 ClawBench 测到的不是“模型会不会浏览网页”,而是一条很长的可靠性链: **读取用户数据 → 理解约束 → 规划步骤 → 操作动态页面 → 保持字段准确 → 处理登录和校验 → 发起最终提交 → 用服务器侧证据确认完成。** 当前模型最常见的失败也不只是规划错误。它们会编造字段、在动态 DOM 中迷路、陷入重复操作、过早退出,或者已经站在 Place Order 按钮前,却没有走完最后一步。 论文中的 Agent-as-Judge 为这些复杂轨迹提供了可扩展、可追溯的判分方式。它比相信 Agent 自报成功可靠,也比只看最后一张截图更完整。但可审计不等于已经被充分证明可靠:Sonnet 组的人工一致性证据很强,GPT-5.4 组的 raw agreement 却被大量共同负例抬高,正例判断实际上完全错位。 因此,ClawBench 给出的最重要提醒有两个: 一是浏览器 Agent 离“可靠代办日常事务”还有明显距离;二是当 benchmark 本身依赖 Agent 判分时,我们也必须像审计被评模型一样审计 Judge。否则,排行榜上的小数点很精确,背后的“通过”却未必同样清楚。 ## 参考资料 - [ClawBench 论文,arXiv v2,2026-07-20](https://arxiv.org/abs/2604.08523):论文版本、V1 任务集、模型结果与 Judge 人工一致率。 - [ClawBench 官网排行榜](https://claw-bench.com/):V2/Hermes 的任务规模、两阶段评分和 2026-05-20 排行榜快照。 - [`eval/agentic_eval.md`](https://github.com/TIGER-AI-Lab/ClawBench/blob/main/eval/agentic_eval.md):V1 Agent-as-Judge 的输入、rubric 与轨迹判定边界。 - [`eval/README.md`](https://github.com/TIGER-AI-Lab/ClawBench/blob/main/eval/README.md):批量 evaluator 与 supervisor 的执行方式。 - [`eval/scoring.md`](https://github.com/TIGER-AI-Lab/ClawBench/blob/main/eval/scoring.md):V2 的 Intercepted、Reward、strict Reward 及 payload Judge 规则。 - V1 任务示例:[Uber Eats #1](https://github.com/TIGER-AI-Lab/ClawBench/blob/main/test-cases/v1/001-daily-life-food-uber-eats/task.json)、[Greenhouse #86](https://github.com/TIGER-AI-Lab/ClawBench/blob/main/test-cases/v1/086-job-search-hr-cv-autofill-greenhouse-meta/task.json)、[Doodle #137](https://github.com/TIGER-AI-Lab/ClawBench/blob/main/test-cases/v1/137-office-secretary-tasks-calendar-doodle/task.json)、[Trello #142](https://github.com/TIGER-AI-Lab/ClawBench/blob/main/test-cases/v1/142-office-secretary-tasks-collab-trello/task.json)与 [Overleaf #215](https://github.com/TIGER-AI-Lab/ClawBench/blob/main/test-cases/v1/215-academia-research-paper-tables-overleaf/task.json)。
https://www.mkshell.com/posts/codex-claude-code-opencode-harness/ https://www.mkshell.com/posts/codex-claude-code-opencode-harness.md 2026-07-22 # 同样是 Coding Agent,Codex 和 Claude Code 的 Harness 到底差在哪?Opencode弱在哪? > 这是一篇运行时对运行时的对照。它不比较 GPT 和 Claude 哪个模型更聪明,而是比较模型在一轮工作中怎样被驱动、受什么约束、从哪里恢复状态、看见什么上下文、怎样调用工具,以及工具究竟在哪个运行环境中落地。 ## 先看地图:一轮 Coding Agent 从开始到结束 Harness 可以拆成六个要素:流程、约束、持久化、上下文、工具、运行环境。它们不是六个并列功能页,而是一条会反复循环的执行链。本文沿着任务真实经过的顺序下钻:先决定这一轮怎么走,再加载边界和状态,组装模型输入,处理工具调用,最后在宿主环境执行并把结果写回。 ```mermaid flowchart TD S([开始:用户提交“修复 checkout 超时”]) --> F["① 流程
决定单 Agent、委派、继续、重试或结束"] F --> C["② 约束
规则、技能、审批、Hook、权限策略"] C --> P1["③ 持久化读取
history、summary、附件、项目状态"] P1 --> X["④ 上下文
把任务、约束、状态、已选能力装成 prompt"] X --> M["模型推理"] M --> T{"需要工具或子任务?"} T -- 否 --> P2["③ 持久化写回
保存答复与当前状态"] T -- 工具 --> U["⑤ 工具
发现、校验、调度、截断、规范化"] T -- 子任务 --> F U --> R["⑥ 运行环境
PTY、沙箱、工作目录、网络、MCP transport"] R --> P2 P2 --> D{"任务完成?"} D -- 否 --> N["下一轮:携带新状态"] N --> F D -- 是 --> E([结束:交付结果与可审计记录]) R -. "风险:副作用、网络边界、环境漂移" .-> RR["运行风险"] P2 -. "风险:摘要丢失早期证据" .-> PR["状态风险"] ``` ### 阅读路线 | 章节 | 在上图中的位置 | 它回答的问题 | | ----------- | ------------------ | -------------------------------------------------- | | 1. 流程 | 开始、分支、下一轮 | 谁决定继续、委派、重试或停止? | | 2. 约束 | 模型与执行器前 | 哪些项目规则和权限边界必须同时生效? | | 3. 持久化 | 读取与写回 | 跨回合究竟保存什么,压缩时又改写什么? | | 4. 上下文 | 模型调用前 | 本轮 prompt 怎样由任务、规则、history 和能力组成? | | 5. 工具 | 模型调用后 | 工具说明何时入窗,结果如何不撑爆下一轮? | | 6. 运行环境 | 工具真正落地时 | shell、网络、MCP 到底在哪个边界内执行? | 一句话结论:Codex 更倾向把流程、策略、会话与执行环境的开关显式交给宿主;Claude Code 2.1.88 更倾向把探索、工具包装和会话整理封装为默认运行时策略。前者的价值是可配置、可审计,后者的价值是低摩擦、默认路径完整。 ## 版本与四层证据边界 | 角色 | Codex | Claude Code | OpenCode | | ---------------------- | ----------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | | 主要证据:解释内部机制 | [Codex 源码备份,ae057e0](https://github.com/aimreant/codex-backup/tree/ae057e0bb9f3cde31c54fcca59a78456f7805282) | [CC 2.1.88 镜像备份,c8cd253](https://github.com/aimreant/claude-code-2.1.88-backup/tree/c8cd253554319f32ff64ff7000636199f720c9bc) | [OpenCode 源码备份,d2bd7ea](https://github.com/aimreant/opencode-backup/tree/d2bd7ea) | | 辅助证据:解释用户实践 | [OpenAI Cookbook](https://developers.openai.com/cookbook) | [Claude Code 官方手册](https://code.claude.com/docs/en/how-claude-code-works) | [OpenCode 官方文档](https://opencode.ai/docs/agents/) | 本文的证据纪律很简单: - **源码**回答“某段 runtime 实际读了什么、写了什么、走了什么分支”。 - **Cookbook**回答“开发者可怎样把这类能力用于任务设计”。它不证明 Codex 内部一定采用某个 cookbook 模式。 - **CC 官方手册**回答“今天产品向用户承诺什么”。它不反向证明 2.1.88 内部对象仍然相同。 - **OpenCode 官方源码/文档**只证明固定 release 的公开实现或当前可配置行为;Issue 和 PR 一律只是演进信号。 - CC 2.1.88 是从 npm source map 提取的非官方历史快照;其版本背景可参见 [官方仓库记录](https://github.com/anthropics/claude-code/issues/41666)。所有 CC 代码段都只解释历史实现。 ## 源码链怎样证明一个 Harness 模块 一段孤立代码通常只能说明“存在一个字段或函数”,不能说明一个模块真正怎样工作。本文每条源码链都只证明一个最小闭环: | 链路位置 | 要回答的问题 | 不能越界推断的内容 | | ----------- | ---------------------------------------- | ------------------------------ | | ① 读取/恢复 | 状态、规则或请求从哪里来? | 它不单独证明后续一定执行。 | | ② 决策/执行 | 什么条件改变路径、预算、权限或委派? | 它不单独证明结果已写回。 | | ③ 写回/回传 | 哪个对象回到 history、父线程或外部环境? | 它不单独证明被保留的信息无损。 | 因此,代码块中的 A/B/C 是同一机制切片,不是完整调用栈。为避免把跨文件片段伪装成可直接编译的程序,块内省略了无关类型、错误处理和参数,且只保留连接三步所必需的语句;每个片段的真实文件和行号都在紧邻段落中列出。相邻的链路 I/O 会用同一任务输入把三步连起来;图中的 ①/②/③ 节点与它们一一对应。 ## 1. 流程:一轮任务由谁推进、何时换人 ### 模块目标 流程模块的输入是用户任务、当前回合状态和可选子任务;输出是一个明确的下一步:继续模型回合、执行工具、委派子会话、重试或结束。它的核心不变量是:复杂探索可以离开主线程,但回到主线程的必须是可用结论,而不是无限增长的原始过程。 ### 用人话说 这像项目负责人安排工作:能直接答的就答,需要查资料的派人查,查完再决定继续还是收工。流程模块要保证“派人”和“回来汇报”都有边界。 ### 常用场景 - 修复 checkout 超时前,先并行定位支付入口、测试失败点和最近改动。 - 一条命令失败后,决定缩小查询、改用另一工具,还是终止任务。 - 主线程不应装入陌生仓库侦察产生的全部 grep 命中。 ### 模块机制图 ```mermaid flowchart TD S([开始:收到任务与当前回合状态]) --> A["① 读取任务、已有状态与委派参数"] A --> B{"可由主线程直接完成?"} B -- 是 --> C["运行模型回合"] B -- 否 --> D{"需要独立探索?"} D -- 是 --> E["② 创建子会话,分配 history 与策略"] E --> F["③ 子会话返回证据、结论、未解项"] F --> C D -- 否 --> C C --> G{"继续、重试还是完成?"} G -- 继续 --> H["把下一动作交给工具或下一轮"] H --> I([结束:进入约束与状态读取]) G -- 完成 --> J([结束:交付最终答复]) E -. "风险:子任务带入过多 history 或权限" .-> R["隔离失效"] ``` 读图结论:流程模块不是“多 Agent 功能列表”,而是控制面。子 Agent 只是其中一条分支,必须在返回主线程前压缩成对主任务有用的证据。 ### Codex 源码解读 下面不是完整调用栈,而是一条委派最小闭环:先读取调用方给定的 history 语义,再创建受策略约束的 child,最后把 child turn 的结论交还主流程。片段 A 见 [codex_delegate.rs,第 63–98 行](https://github.com/aimreant/codex-backup/blob/ae057e0bb9f3cde31c54fcca59a78456f7805282/codex-rs/core/src/codex_delegate.rs#L63-L98),片段 B/C 见 [第 105–138 行](https://github.com/aimreant/codex-backup/blob/ae057e0bb9f3cde31c54fcca59a78456f7805282/codex-rs/core/src/codex_delegate.rs#L105-L138)(公开源码备份,2026-03-31 快照)。 ```rust // 片段 A:读取。调用者没有指定时,默认是 New history。 let history = initial_history.unwrap_or(InitialHistory::New); // 片段 B:决策/创建。父级执行策略不是子会话的隐式全权限。 let child = Codex::spawn(CodexSpawnArgs { conversation_history: history, inherited_exec_policy: Some(inherited_exec_policy), config: child_config, skills_manager, }).await?; // 片段 C:写回。主流程消费的是 child turn 的结果,不是 child 内存。 let result = run_child_turn(child, task).await?; return Ok(result); ``` | I/O 项 | 链路 I/O:查找支付链路 | | ------------ | ----------------------------------------------------------------------------------------------- | | 输入 | 主线程委派“找出支付入口、签名校验、回调处理文件”,并选用 InitialHistory::New。 | | 状态变化 | A 把 history 定义为 New 或继承;B 将该选择与父级策略写入 child;C 等待并取得 child 的完成结果。 | | 输出 | 主流程得到子会话回传的文件路径、调用顺序和未确认分支。 | | 用户可见结果 | 主线程得到可继续验证的摘要,而不是所有搜索噪声。 | | 误判风险 | New history 也会丢掉业务限定;任务描述模糊时可能查到错误支付链路。 | **Cookbook 实践旁证(辅助,不证明上述实现)**:Cookbook 的 [Orchestrating Agents: Routines and Handoffs](https://cookbook.openai.com/examples/orchestrating_agents) 将“按任务转交给另一 routine”作为工作流设计手法。它支持本文的实践建议:只有当子任务能用清晰产物回传时才委派;但它不说明 Codex 内部如何 spawn 子会话。 ### CC 2.1.88 源码解读 CC 2.1.88 的相应闭环由运行入口和 Agent 定义共同构成:入口准备子会话,Explore 定义收窄其角色,运行结果再回到父调用。片段 A/C 见 [runAgent.ts,第 85–217 行](https://github.com/aimreant/claude-code-2.1.88-backup/blob/c8cd253554319f32ff64ff7000636199f720c9bc/source/src/tools/AgentTool/runAgent.ts#L85-L217),片段 B 见 [exploreAgent.ts,第 24–56 行](https://github.com/aimreant/claude-code-2.1.88-backup/blob/c8cd253554319f32ff64ff7000636199f720c9bc/source/src/tools/AgentTool/built-in/exploreAgent.ts#L24-L56)(非官方历史快照备份)。 ```ts // 片段 A:入口选择 agent definition,并建立 agent session。 const definition = resolveAgent(agentName); const session = await createAgentSession(definition, input); // 片段 B:Explore 的可用动作和模型被定义为只读探索。 const exploreAgent = { tools: ['Glob', 'Grep', 'Read'], model: 'haiku', skipClaudeMd: true, }; // 片段 C:运行器将完成结果格式化后回给 Task 的父调用。 const result = await runAgent(session, definition, prompt); return formatAgentResult(result); ``` | I/O 项 | 链路 I/O:查找支付链路 | | ------------ | --------------------------------------------------------------------------------------- | | 输入 | 主 Agent 把同一条支付链路侦察任务交给 Explore。 | | 状态变化 | A 为 agent 创建执行状态;B 决定其工具面和模型;C 把运行结果转为父工具调用可消费的产物。 | | 输出 | 返回文件定位、可能调用链和下一步验证建议。 | | 用户可见结果 | 主会话不被大量目录、grep 命中和阅读过程淹没。 | | 误判风险 | 2.1.88 的 skipClaudeMd 会让探索更轻,却可能漏掉项目专有规则。 | **当前官方手册旁证(辅助)**:[Subagents 文档](https://code.claude.com/docs/en/sub-agents) 说明当前产品支持独立上下文、工具和模型配置;它不能证明当前 Explore 仍使用上述工具或跳过规则。 ### 实现对比与取舍 | 维度 | Codex | CC 2.1.88 | 直接结论 | | ---------- | -------------------------------------------------- | -------------------------------------------------- | -------------------------------------------------------------------------- | | 目标达成 | 调用方显式指定 New/继承 history 与执行策略。 | Explore 默认收窄为只读探索,并封装模型路由与提示。 | Codex 保留编排权;CC 2.1.88 缩短仓库侦察的设置路径。 | | 可观察性 | spawn 参数直接暴露子会话输入和策略。 | Agent 定义可读,但证据仅覆盖历史快照。 | Codex 更适合复现“子 Agent 为什么这样做”;CC 的当前行为要回到官方手册核验。 | | 默认自动化 | 主 Agent 必须自行决定怎样拆分和回收任务。 | Explore 默认隔离搜索噪声,并禁止写文件。 | Codex 的代价是调度代码更多;CC 的代价是 Explore 可能跳过项目专有上下文。 | | 成本与风险 | 并发数、history 模式和回传体积都由调用方负责限制。 | 默认角色降低上手成本,但摘要仍可能遗漏关键证据。 | Codex 适合需要精确控制的并行;CC 适合先建立只读证据,再由主线程执行改动。 | | 适合任务 | 多个独立验证、需继承指定策略的实现任务。 | 陌生仓库侦察、只读依赖追踪。 | 改动关键路径前,Codex 与 CC 都应让主线程复查源码或测试结果。 | ### OpenCode:现状与改进路线 | 已具备的公开能力 | 相对本章目标仍需补强的闭环 | 建议改进方向 | | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 官方 [Agents 文档](https://opencode.ai/docs/agents/) 公开了 Task 子 Agent 与工具权限;稳定参考点固定为 [v1.15.11 的 d2bd7ea](https://github.com/aimreant/opencode-backup/tree/d2bd7ea)。 | Task 子 Agent 的公开模型更接近“顺序运行、返回后终止”;多 Agent 并行、可选 fork/new history、结构化 join 不是同一条稳定主链的已证实保证。 | 让 Task 显式携带 history mode、返回 schema 与父级策略;先实现可审计的 join,再引入并行和协作消息。有关团队协作的 Issue 只作为[演进信号](https://github.com/anomalyco/opencode/issues/12711),不是已发布能力。 | ### 适用场景 | 明确结论:优先选 Codex | 明确结论:优先选 CC 2.1.88 | 共同警戒 | | ------------------------------------------------------ | ---------------------------------------------------- | ----------------------------------------------------- | | **要继承架构决策、审计子会话或控制并发时,选 Codex。** | **只读侦察陌生仓库、隔离搜索噪声时,选 CC 2.1.88。** | 子 Agent 的摘要不是证据;关键路径仍要读源码或跑测试。 | ## 2. 约束:哪些规则必须在模型前和执行时都生效 ### 模块目标 约束模块将系统指令、项目规则、技能、审批、Hook 与权限策略转成可执行边界。输入是仓库和宿主提供的规则、工具请求和风险目标;输出是受约束的 prompt、允许/拒绝/待审批的动作。核心不变量是:模型看见一条规则不等于运行时会执行它,真正的副作用还需在执行点复核。 ### 用人话说 规则像施工图和门禁:施工图告诉工人不能拆承重墙,门禁则在真要进机房时拦下来。只把规则写进 prompt,相当于只贴了提示牌;只在执行时阻拦,又会让模型白做规划。 ### 常用场景 - 项目规定只能改 src/payment,不能改数据库 schema。 - 访问 api.example.com:443 前必须让用户单次确认。 - 团队用 Hook 阻止未经测试的提交或生产写操作。 ### 模块机制图 ```mermaid flowchart TD S([开始:任务与候选动作进入]) --> A["① 读取系统、项目、技能与策略规则"] A --> B["把稳定约束提供给上下文装配"] B --> C{"模型请求是否触及受控动作?"} C -- 否 --> D["保留约束快照"] C -- 是 --> E["② 运行时按审批和权限策略复核"] E -- 允许 --> D E -- 拒绝或待确认 --> F["③ 写回拒绝或审批请求"] D --> G([结束:进入状态读取或工具调用]) F --> G E -. "风险:只靠 prompt、策略粒度过宽" .-> R["约束绕过"] ``` 读图结论:约束有两次落点:进入 prompt 时影响计划,进入执行器时阻止副作用。本文把前者放在“上下文”的输入中,把后者留在“运行环境”的真实边界。 ### Codex 源码解读 Codex 的约束闭环需要同时看“规则怎样进入 session”和“动作怎样在执行点被复核”。片段 A 是 [compact.rs,第 191–223 行](https://github.com/aimreant/codex-backup/blob/ae057e0bb9f3cde31c54fcca59a78456f7805282/codex-rs/core/src/compact.rs#L191-L223) 中稳定上下文重建;片段 B/C 是 [network_approval.rs,第 77–127 行](https://github.com/aimreant/codex-backup/blob/ae057e0bb9f3cde31c54fcca59a78456f7805282/codex-rs/core/src/tools/network_approval.rs#L77-L127) 的审批与结果分支(公开源码备份)。 ```rust // 片段 A:读取。稳定规则来自 session,而不是只靠模型记住。 let initial_context = sess.build_initial_context(turn_context.as_ref()).await; // 片段 B:决策。审批对象由目标主机、协议和端口共同组成。 let key = HostApprovalKey { host, protocol, port }; let decision = approval_policy.check(&key); // 片段 C:写回或执行。AskUser 产生事件,不等同于放行。 match decision { Allow => execute_request(), AskUser => request_network_approval(key), Deny => deny_request(), } ``` | I/O 项 | 链路 I/O:首次访问 api.example.com:443 | | ------------ | -------------------------------------------------------------------------------- | | 输入 | 模型要求请求 https://api.example.com:443/v1/status,策略中尚无预授权。 | | 状态变化 | A 恢复稳定约束;B 把请求解析成审批键;C 将策略结果落实为执行、拒绝或待审批事件。 | | 输出 | 请求执行、返回拒绝,或生成等待用户确认的 approval。 | | 用户可见结果 | 用户可针对具体目标确认,而不是笼统地“允许联网”。 | | 误判风险 | 重定向、子域名和不同端口不自动继承这一次授权。 | **Cookbook 实践旁证(辅助)**:[OpenAI Cookbook 目录](https://developers.openai.com/cookbook) 中的工具与 agent recipe 可用于设计 guardrail 与人工确认工作流;截至本文查阅,没有一篇直接描述 Codex 的 host/protocol/port 审批键,因此不以 cookbook 取代源码。 ### CC 2.1.88 源码解读 CC 2.1.88 的机制链由 context 规则来源、MCP 包装的权限调用与 client 调用分支共同组成。片段 A 见 [context.ts,第 152–188 行](https://github.com/aimreant/claude-code-2.1.88-backup/blob/c8cd253554319f32ff64ff7000636199f720c9bc/source/src/context.ts#L152-L188),片段 B/C 见 [MCPTool.ts,第 27–75 行](https://github.com/aimreant/claude-code-2.1.88-backup/blob/c8cd253554319f32ff64ff7000636199f720c9bc/source/src/tools/MCPTool/MCPTool.ts#L27-L75)(非官方历史快照备份)。 ```ts // 片段 A:读取。项目规则和记忆先聚合为执行上下文。 const context = await buildContext({ messages, claudeMd, memory }); // 片段 B:决策。MCP 包装把工具名和参数送进外层权限链。 const permission = await context.canUseTool(this.name, input); // 片段 C:写回或执行。拒绝被透传,允许后才调用服务。 if (!permission.allowed) return permission.result; return this.client.callTool(this.serverName, this.toolName, input); ``` | I/O 项 | 链路 I/O:首次访问 api.example.com:443 | | ------------ | ------------------------------------------------------------------------------ | | 输入 | MCP 工具参数含 api.example.com 目标。 | | 状态变化 | A 聚合可用规则来源;B 调用通用权限链;C 把拒绝回传或将获准请求交给 client。 | | 输出 | 允许时执行;否则把拒绝或待确认结果回传。 | | 用户可见结果 | 权限交互由上层策略统一呈现,而不是由每个 MCP 工具自行决定。 | | 误判风险 | 此片段不能证明 CC 2.1.88 采用 host/port 粒度;它只证明存在先判定后调用的边界。 | **当前官方手册旁证(辅助)**:[Permissions](https://code.claude.com/docs/en/permissions) 与 [Hooks](https://code.claude.com/docs/en/hooks) 说明当前用户可配置的权限和生命周期约束;不要把它们的当前配置名外推回 2.1.88。 ### 实现对比与取舍 | 维度 | Codex | CC 2.1.88 | 直接结论 | | ---------- | ------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------------- | | 目标达成 | 审批键直接包含 host、协议和端口。 | MCP 工具将请求交给统一的外层权限链。 | Codex 便于按网络目标最小授权;CC 便于复用团队已有的 MCP 策略。 | | 可观察性 | 审计记录可直接对应到目标 host、协议和端口。 | 可看到是否经过 `canUseTool`,细粒度由外层策略决定。 | Codex 更容易回答“放行了哪个地址”;CC 必须继续检查外层规则是否足够细。 | | 默认自动化 | 拒绝、询问、允许由显式策略分支决定。 | MCP 工具复用同一套权限判断。 | Codex 每个目标都可单独确认;CC 减少重复判断,但共享策略会同时影响所有匹配工具。 | | 成本与风险 | 细粒度规则带来更多配置与确认。 | 宽泛的外层规则会扩大每个匹配 MCP 的权限。 | Codex 的代价是操作更慢;CC 的风险是一次宽放行覆盖多项能力。 | | 适合任务 | 生产网络访问、最小授权、需留痕的团队。 | 已有统一 MCP 权限层的工作流。 | 涉及写操作或重定向时,Codex 与 CC 都要按真实目标重新判定权限。 | ### OpenCode:现状与改进路线 | 已具备的公开能力 | 相对本章目标仍需补强的闭环 | 建议改进方向 | | -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | [Agents 文档](https://opencode.ai/docs/agents/) 公开 allow、ask、deny 和基于工具名模式的权限配置。 | 公开配置能说明“允许哪个工具”,但本文选取的 Codex 对照还要求把项目规则、目标级能力和执行审计形成同一条可追踪链。 | 将项目规则编译为可引用的约束快照;将高风险网络/MCP 调用发成含目标、理由和结果的 capability event。不要把工具 wildcard 当作环境级最小授权。 | ### 适用场景 | 明确结论:优先选 Codex | 明确结论:优先选 CC 2.1.88 | 共同警戒 | | ---------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------- | | **要按目标审计网络访问、显式处理审批时,选 Codex。** | **已有统一 MCP 权限与 Hook 体系时,选 CC 2.1.88。** | 规则不能只写进 prompt;拒绝宽通配符和生产默认放行。 | ## 3. 持久化:跨回合留下什么,窗口满时改写什么 ### 模块目标 持久化模块负责从 session 中恢复可继续工作的状态,并在一轮完成后写回新的消息、工具结果、摘要和附件关系。输入是已存 history 与当前回合产物;输出是下一轮能读取的状态。核心不变量是:压缩可以是有损的,但必须保住当前目标、标记边界,并让稳定规则不只寄托于摘要。 ### 用人话说 这像交接班。下一班不需要重看所有监控录像,但必须知道事故还没结束、已经排除了什么、报告附件在哪里。压缩不是删除历史的借口,而是写一份能继续干活的交接单。 ### 常用场景 - 120 轮排障后又出现一批测试日志。 - 用户隔天回来继续同一个修复任务。 - 长会话里既有文本、工具输出又有附件。 ### 模块机制图 ```mermaid flowchart TD S([开始:① 读取 session history 与附件]) --> A{"窗口是否足够?"} A -- 足够 --> B["恢复原 history"] A -- 不足 --> C["选择旧 history、近期目标、附件"] C --> D["② 生成 compact summary"] D --> E{"摘要请求是否超窗或失败?"} E -- 否 --> F["重建 boundary、summary、保留消息与附件"] E -- 是 --> G["退避:裁短输入后重试"] G --> C B --> H["写入本轮新消息与工具结果"] F --> H H --> I([结束:③ 保存下一轮可恢复状态]) F -. "风险:早期反例被摘要吞掉" .-> R["状态失真"] ``` 读图结论:持久化不是数据库名词,而是让下一轮能继续做对事的状态迁移。压缩策略的关键在于保留对象、失败退避与边界标记。 ### Codex 源码解读 Codex 的持久化闭环由 history 快照、超窗退避和 replacement history 重建组成。片段 A/C 见 [compact.rs,第 191–223 行](https://github.com/aimreant/codex-backup/blob/ae057e0bb9f3cde31c54fcca59a78456f7805282/codex-rs/core/src/compact.rs#L191-L223),片段 B 见 [第 154–230 行与 324–389 行](https://github.com/aimreant/codex-backup/blob/ae057e0bb9f3cde31c54fcca59a78456f7805282/codex-rs/core/src/compact.rs#L154-L230)(公开源码备份)。 ```rust // 片段 A:读取。先取得本轮可替换的 session history 快照。 let history_snapshot = sess.clone_history().await; let history_items = history_snapshot.raw_items(); // 片段 B:决策。compact 自身超窗时,按最早项退避再重试。 if turn_input_len > 1 { history.remove_first_item(); continue; } // 片段 C:写回。summary、近期用户目标与重建规则组成新 history。 let new_history = build_compacted_history(Vec::new(), &user_messages, &summary_text); sess.replace_history(new_history).await; ``` | I/O 项 | 链路 I/O:120 轮排障后再次超窗 | | ------------ | ---------------------------------------------------------------------------------------------------- | | 输入 | history 含 120 轮消息和工具结果;最新请求是“继续验证支付回调是否重复扣款”。 | | 状态变化 | A 读取当前可替换状态;B 对过长摘要请求删除最早项并重试;C 用摘要和保留对象写回 replacement history。 | | 输出 | 生成更短 replacement history,或向上层返回最终失败。 | | 用户可见结果 | 会话通常可继续,当前目标更可能留在新状态中。 | | 误判风险 | 保住最新目标不等于保住早期日志中的关键反例。 | **Cookbook 实践旁证(辅助)**:[OpenAI Cookbook 目录](https://developers.openai.com/cookbook) 收录的长文总结和 agent 状态处理类示例可帮助设计“摘要后仍可验证”的工作流,但没有直接公开 Codex compact 的保留算法;本文的删除顺序与重试结论只来自源码。 ### CC 2.1.88 源码解读 CC 2.1.88 的闭环是:检测压缩边界、生成摘要、用 boundary/消息/附件构成新数组。片段 A/C 见 [compact.ts,第 325–334 行](https://github.com/aimreant/claude-code-2.1.88-backup/blob/c8cd253554319f32ff64ff7000636199f720c9bc/source/src/services/compact/compact.ts#L325-L334),片段 B 见 [第 596–610 行](https://github.com/aimreant/claude-code-2.1.88-backup/blob/c8cd253554319f32ff64ff7000636199f720c9bc/source/src/services/compact/compact.ts#L596-L610)(非官方历史快照备份)。 ```ts // 片段 A:读取。compact 选择保留的消息与附件。 const { messagesToKeep, attachments } = selectPostCompactState(messages); // 片段 B:决策。旧 history 被折叠为模型生成的 summary。 const summary = await createCompactSummary(messages); // 片段 C:写回。新的边界、摘要、近期消息和附件按顺序重建。 return [ createCompactBoundaryMessage(), createSummaryMessage(summary), ...messagesToKeep, ...attachments, ]; ``` | I/O 项 | 链路 I/O:120 轮排障后再次超窗 | | ------------ | --------------------------------------------------------------------------------------- | | 输入 | 同一长 history、当前支付回调目标,且有一份仍需引用的报表附件。 | | 状态变化 | A 选择保留对象;B 将旧对话压为 summary;C 把边界、summary、近因消息和附件重建为新状态。 | | 输出 | 一个结构化的 post-compact message 列表。 | | 用户可见结果 | 后续回合能知道状态已压缩,附件也不会只剩一段文字描述。 | | 误判风险 | 边界标记不等于摘要准确;遗漏的反例仍会消失。 | **当前官方手册旁证(辅助)**:[Context window](https://code.claude.com/docs/en/context-window) 确认当前产品会管理上下文窗口;它不能确定今天仍以 2.1.88 的对象顺序重建 history。 ### 实现对比与取舍 | 维度 | Codex | CC 2.1.88 | 直接结论 | | ---------- | -------------------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------- | | 目标达成 | 用最近用户目标和摘要续接任务,并在超窗时退避重试。 | 用 boundary、summary、保留消息和附件重建 history。 | Codex 优先维持会话连续;CC 2.1.88 显式保留附件与压缩边界。 | | 可观察性 | 删除最早项和重试路径可在公开源码定位。 | 重建数组结构清楚,但只由历史镜像证明。 | 调试压缩故障时,Codex 的证据更稳定;CC 的当前对象顺序不能据此断言。 | | 默认自动化 | 重试减少会话被超窗直接打断。 | boundary 将摘要后的 history 切出明确分界。 | Codex 用额外延迟换连续性;CC 用固定边界换可读性。 | | 成本与风险 | 多轮摘要重试会拉长回合时间。 | 摘要偏差会被写进新的 boundary,随后持续影响推理。 | Codex 要监控重试次数;CC 要把关键事实保存在摘要之外的持久载体。 | | 适合任务 | 需要查清压缩退避行为的长调试。 | 带附件的长研究或文档会话。 | 证据、堆栈和关键配置不应只存在于 Codex 或 CC 的摘要中。 | ### OpenCode:现状与改进路线 | 已具备的公开能力 | 相对本章目标仍需补强的闭环 | 建议改进方向 | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | | OpenCode 公开源码备份含 [session compaction 服务](https://github.com/aimreant/opencode-backup/blob/d2bd7ea/packages/opencode/src/session/compaction.ts),官方产品也有 session 概念。 | “压缩后 durable instructions 是否稳定重注入、附件和证据怎样可回放”不能仅从已有公开入口推断。社区关于规则在压缩后丢失的报告只能视为[问题信号](https://github.com/anomalyco/opencode/issues/16960)。 | 让 compact 输出成为带版本的结构化 envelope:保留规则快照、摘要来源范围、附件引用和丢弃清单;再为每次 compact 写入可查询 trace。 | ### 适用场景 | 明确结论:优先选 Codex | 明确结论:优先选 CC 2.1.88 | 共同警戒 | | -------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------ | | **要追踪压缩退避、删除与重试时,选 Codex。** | **附件必须跨压缩保留、需要显式 boundary 时,选 CC 2.1.88。** | 合规证据、堆栈和关键配置不得只存在于摘要。 | ## 4. 上下文:本轮模型究竟带了哪些行李 ### 模块目标 上下文模块把已生效约束、恢复状态、当前用户任务和此刻必要的工具说明装配成模型请求。输入来自前三章,输出是本轮 prompt。核心不变量是:稳定规则要能重注入,易失 history 要有预算,工具 schema 要按必要程度进入,而不是无差别塞满窗口。 ### 用人话说 Agent 出门要带工作证、目的地、上次交接单和必要工具。把所有仓库资料和所有工具说明都装进背包会超重;只带摘要又会忘记“不能改 schema”这种硬约束。 ### 常用场景 - 有目录、测试、审批限制的仓库修复。 - 从已经压缩过的会话继续实现。 - 同时接入许多 MCP、技能和工具,但首轮 token 预算有限。 ### 模块机制图 ```mermaid flowchart TD S([开始:① 接收任务、约束与可恢复状态]) --> A["读取稳定规则与当前用户目标"] A --> B["取回 history、summary、附件引用"] B --> C{"哪些能力本轮必要?"} C -- 常驻 --> D["② 加入必要工具 schema 与技能说明"] C -- 按需 --> E["② 保留 Tool Search 等发现入口"] D --> F["按预算装配 prompt"] E --> F F --> G([结束:③ 向模型发送本轮输入]) F -. "风险:规则漏注入、schema 过大、摘要被当原文" .-> R["上下文失真"] ``` 读图结论:上下文不是历史的原样拼接,而是一个预算内的装配产物。约束、持久化和工具三层在此相遇,但它们的治理逻辑各自独立。 ### Codex 源码解读 Codex 的上下文闭环可分成三层:从 history 提取近期目标、从 session 重建稳定约束、把两者插入 replacement history;按需工具发现再避免未使用 schema 长驻。片段 A/B 见 [compact.rs,第 191–223 行](https://github.com/aimreant/codex-backup/blob/ae057e0bb9f3cde31c54fcca59a78456f7805282/codex-rs/core/src/compact.rs#L191-L223),片段 C 见 [tool_search.rs](https://github.com/aimreant/codex-backup/blob/ae057e0bb9f3cde31c54fcca59a78456f7805282/codex-rs/core/src/tools/handlers/tool_search.rs)(公开源码备份)。 ```rust // 片段 A:读取。近期用户目标从 history snapshot 提取。 let user_messages = collect_user_messages(history_items); // 片段 B:决策/装配。稳定规则由当前 session 重建后插入新 history。 let initial_context = sess.build_initial_context(turn_context.as_ref()).await; new_history = insert_initial_context_before_last_real_user_or_summary( new_history, initial_context); // 片段 C:按需能力。Tool Search 只在模型选择发现时返回匹配工具说明。 let discovered = tool_search_handler(query, deferred_tools).await?; ``` | I/O 项 | 链路 I/O:修复 checkout 超时 | | ------------ | ----------------------------------------------------------------------------- | | 输入 | 旧测试输出已压缩;session 仍有“只能改 src/payment、不得改 schema”的项目约束。 | | 状态变化 | A 提取近期意图;B 从 session 恢复并插入稳定规则;C 只在需要时发现额外能力。 | | 输出 | 新 history 同时带近期意图、summary、重注入规则和少量已选工具说明。 | | 用户可见结果 | 下一轮模型仍能看到不能改 schema,不必完全依赖摘要记住。 | | 误判风险 | 重注入规则不代表旧日志、旧工具输出或全部项目资料也被恢复。 | **Cookbook 实践旁证(辅助)**:[OpenAI Cookbook 目录](https://developers.openai.com/cookbook) 中的 agent、prompt 与工具示例能帮助开发者把稳定指令、状态和工具描述拆层;它不提供 Codex 的 initial_context 组装顺序,因此顺序结论仍仅由源码支持。 ### CC 2.1.88 源码解读 CC 2.1.88 的链同样不应只看 Tool Search:先聚合 messages、CLAUDE.md 和 memory,再以 query 选 deferred tools,最后把它们送给本轮模型。片段 A/C 见 [context.ts,第 152–188 行](https://github.com/aimreant/claude-code-2.1.88-backup/blob/c8cd253554319f32ff64ff7000636199f720c9bc/source/src/context.ts#L152-L188),片段 B 见 [ToolSearchTool.ts,第 21–45 行](https://github.com/aimreant/claude-code-2.1.88-backup/blob/c8cd253554319f32ff64ff7000636199f720c9bc/source/src/tools/ToolSearchTool/ToolSearchTool.ts#L21-L45)(非官方历史快照备份)。 ```ts // 片段 A:读取。会话、项目规则和 memory 汇入 context。 const context = await buildContext({ messages, claudeMd, memory }); // 片段 B:决策。deferred tools 由 query 选择,而不是全量注入。 const tools = searchDeferredTools(query); // 片段 C:写入模型输入。context 与已选 tools 共同构成请求。 return createModelRequest({ context, tools, messages }); ``` | I/O 项 | 链路 I/O:修复 checkout 超时 | | ------------ | ---------------------------------------------------------------- | | 输入 | 当前任务、会话消息、CLAUDE.md/memory 与一组 deferred tools。 | | 状态变化 | A 聚合规则与会话;B 选择匹配工具;C 把两者写为模型请求。 | | 输出 | 供模型使用的 context 与一小部分被发现的工具能力。 | | 用户可见结果 | 首轮不必背负所有工具 schema,项目记忆更贴近任务。 | | 误判风险 | 自动载入的记忆也会占预算;历史实现显示的加载细节不代表当前产品。 | **当前官方手册旁证(辅助)**:[How Claude Code works](https://code.claude.com/docs/en/how-claude-code-works) 说明当前产品如何使用项目指令与上下文;今天具体的自动加载时机仍以官方文档为准。 ### 实现对比与取舍 | 维度 | Codex | CC 2.1.88 | 直接结论 | | ---------- | -------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------- | | 目标达成 | 压缩后显式重注入 session 的稳定上下文。 | 聚合项目记忆、会话和 deferred tool 发现。 | Codex 让长期规则的来源更清楚;CC 2.1.88 用自动聚合降低首次配置量。 | | 可观察性 | `initial_context` 的重建与插回可沿源码检查。 | 聚合路径更自动,细节仅由历史快照说明。 | Codex 更适合审计 prompt 构成;CC 的当前加载细节需要查官方手册。 | | 默认自动化 | 宿主维护需要重注入的上下文清单。 | 项目记忆和按需工具默认贴近当前任务。 | Codex 的代价是规则漏配;CC 的代价是自动记忆和工具说明占用 token 预算。 | | 成本与风险 | 漏掉稳定规则会让新会话失焦。 | 无关记忆会挤掉当前任务或关键工具结果。 | Codex 应将重注入清单纳入项目配置;CC 应限制自动载入规则的范围和长度。 | | 适合任务 | 多仓库、强规则、需要审计 prompt 构成。 | 规则和任务记忆较稳定、偏好低摩擦探索。 | Codex 与 CC 都应把硬规则写成短句,并分别记录规则、history 与 schema 的 token 预算。 | ### OpenCode:现状与改进路线 | 已具备的公开能力 | 相对本章目标仍需补强的闭环 | 建议改进方向 | | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | [Agents 文档](https://opencode.ai/docs/agents/) 公开 agent prompt、工具面和权限配置;[Tools 文档](https://dev.opencode.ai/docs/tools/) 公开内建与 MCP 工具。 | 用户能配置上下文来源,不等于能观察每轮 prompt 中规则、history、schema 各占多少预算或何时被移除。 | 引入 context manifest:每轮列出规则、历史、工具 schema 的 token 账本和来源;将按需 MCP 搜索作为稳定能力,而不是仅依赖演进中的 [lazy-MCP PR](https://github.com/anomalyco/opencode/pull/12520)。 | ### 适用场景 | 明确结论:优先选 Codex | 明确结论:优先选 CC 2.1.88 | 共同警戒 | | ------------------------------------------------ | -------------------------------------------------------- | ------------------------------------ | | **强项目约束、需审计 prompt 构成时,选 Codex。** | **偏好项目记忆与按需能力自动贴合任务时,选 CC 2.1.88。** | 不要把“模型看过”误认为规则一定执行。 | ## 5. 工具:模型说要做,工具结果怎样安全回流 ### 模块目标 工具模块把模型的工具调用转成可校验请求,把异构原始输出转成下一轮可读取的 tool result。输入是工具 schema、调用参数与原始结果;输出是规范化、截断状态可见、可写入 history 的结果。核心不变量是:工具发现、调用和结果治理是三件相连但不同的事。 ### 用人话说 工具像一间器材室。模型先要知道有哪些器材,再借某一件,最后拿回结果。拿回一整车 10,000 行日志会堵住走廊,所以 Harness 要裁剪,还得贴上“已裁剪”的标签。 ### 常用场景 - CI、构建和宽搜索产生数千行日志。 - MCP 服务返回大 JSON、表格或分页数据。 - 工具很多,希望 schema 不全部常驻 prompt。 ### 模块机制图 ```mermaid flowchart TD S([开始:① 模型提出工具调用]) --> A["定位已注册工具或执行 Tool Search"] A --> B["② 校验工具名、参数与调用协议"] B --> C["把请求交给运行环境执行"] C --> D["取得原始 stdout、文件或 MCP 结果"] D --> E{"超过结果预算?"} E -- 否 --> F["规范化完整 tool result"] E -- 是 --> G["截断并保留截断事实"] F --> H["③ 写回 history"] G --> H H --> I([结束:模型获得下一轮证据]) G -. "风险:根因在被裁掉的尾部" .-> R["需二次窄读"] ``` 读图结论:Tool Search 解决“说明何时入窗”,截断解决“结果如何回流”。二者都节省上下文,却不能互相替代。 ### Codex 源码解读 Codex 的工具结果闭环至少有三处:先按需发现工具说明,再保留原始执行字节,最后按 token 预算形成写回文本。片段 A 见 [tool_search.rs](https://github.com/aimreant/codex-backup/blob/ae057e0bb9f3cde31c54fcca59a78456f7805282/codex-rs/core/src/tools/handlers/tool_search.rs),片段 B/C 见 [tools/context.rs,第 284–362 行](https://github.com/aimreant/codex-backup/blob/ae057e0bb9f3cde31c54fcca59a78456f7805282/codex-rs/core/src/tools/context.rs#L284-L362);PTY 的原始字节上限见 [pty/src/lib.rs,第 10 行](https://github.com/aimreant/codex-backup/blob/ae057e0bb9f3cde31c54fcca59a78456f7805282/codex-rs/utils/pty/src/lib.rs#L10)。 ```rust // 片段 A:读取/发现。只返回模型查询命中的 deferred tool 定义。 let tools = tool_search_handler(query, deferred_tools).await?; // 片段 B:保存执行事实。原始字节与模型回流预算分离。 let output = ExecCommandToolOutput { raw_output, max_output_tokens, original_token_count, }; // 片段 C:写回。将字节转文本后按 token 预算裁剪为 tool result。 let text = String::from_utf8_lossy(&output.raw_output).to_string(); let visible = formatted_truncate_text(&text, TruncationPolicy::Tokens(max_tokens)); history.push_tool_result(visible); ``` | I/O 项 | 链路 I/O:10,000 行测试日志 | | ------------ | ------------------------------------------------------------------------------ | | 输入 | raw_output 约 8,000 token,max_output_tokens 为 1,000。 | | 状态变化 | A 仅选入需要的工具说明;B 保存原始字节与预算;C 截断并把可见结果写回 history。 | | 输出 | 约 1,000 token 的结果,且可携带原始 token 规模。 | | 用户可见结果 | 模型应意识到日志不完整,改用 tail、grep 或定点读文件。 | | 误判风险 | 截断边界不保证保留真正的失败根因。 | **Cookbook 实践旁证(辅助)**:[OpenAI Cookbook 目录](https://developers.openai.com/cookbook) 的 function calling、MCP 与 agent tool 示例适合说明“工具要设计成窄输入、窄输出”;没有一篇直接规定 Codex 的 token 截断算法,因此上限与对象字段仍只据源码。 ### CC 2.1.88 源码解读 CC 2.1.88 的工具链包括 deferred tool 发现、MCP Tool 的容量定义与结果截断识别,而不是只是一条 maxResultSizeChars 常量。片段 A 见 [ToolSearchTool.ts,第 21–45 行](https://github.com/aimreant/claude-code-2.1.88-backup/blob/c8cd253554319f32ff64ff7000636199f720c9bc/source/src/tools/ToolSearchTool/ToolSearchTool.ts#L21-L45),片段 B/C 见 [MCPTool.ts,第 27–75 行](https://github.com/aimreant/claude-code-2.1.88-backup/blob/c8cd253554319f32ff64ff7000636199f720c9bc/source/src/tools/MCPTool/MCPTool.ts#L27-L75)(非官方历史快照备份)。 ```ts // 片段 A:读取/发现。query 选择 deferred tools。 const discovered = searchDeferredTools(query); // 片段 B:决策。MCP Tool 将结果字符预算声明为协议的一部分。 const mcp = buildTool({ isMcp: true, maxResultSizeChars: 100_000 }); // 片段 C:写回提示。结果被回传前提供截断状态。 const truncated = isOutputLineTruncated(output); return createToolResult({ output, truncated }); ``` | I/O 项 | 链路 I/O:130,000 字符 MCP 返回 | | ------------ | ---------------------------------------------------------------------- | | 输入 | 支付状态 MCP 返回 130,000 字符 JSON。 | | 状态变化 | A 选择所需能力;B 将容量预算附在工具定义;C 形成带截断事实的回流结果。 | | 输出 | 上限内 tool result 与截断判断信号。 | | 用户可见结果 | 模型应分页、缩小字段或读取特定资源。 | | 误判风险 | 字符上限不是业务对象边界,尾部字段仍可能消失。 | **当前官方手册旁证(辅助)**:[Tools reference](https://code.claude.com/docs/en/tools-reference) 与 [MCP](https://code.claude.com/docs/en/mcp) 描述当前可用工具和 MCP 使用方式;当前结果上限不由 2.1.88 片段推断。 ### 实现对比与取舍 | 维度 | Codex | CC 2.1.88 | 直接结论 | | ---------- | --------------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------ | | 目标达成 | shell 输出按 token 裁剪,原始规模可单独保留。 | MCP Tool 声明字符预算并检测截断。 | Codex 以 token 控制模型输入;CC 2.1.88 在 MCP 包装层统一管理结果容量。 | | 可观察性 | 原始字节、裁剪文本和原始 token 数可分别检查。 | MCP 调用统一经过 Tool 层。 | Codex 更容易定位日志为何被截断;CC 更容易给所有 MCP 套用同一结果协议。 | | 默认自动化 | 调用侧可设置 `max_output_tokens`。 | 容量策略写在工具定义中。 | Codex 允许按任务收紧或放宽预算;CC 让每个 MCP 遵守同一默认上限。 | | 成本与风险 | token 裁剪仍可能丢掉失败位置的关键行。 | 字符裁剪可能截断 JSON,破坏对象完整性。 | Codex 应按文件、测试名或行号二次窄读;CC 应让 MCP 返回分页或字段筛选后的结构化结果。 | | 适合任务 | CI 排障、构建失败、宽搜索日志。 | 多 SaaS/MCP 集成、结构化资源查询。 | Codex 与 CC 都不应以无限提高结果上限替代缩小查询范围。 | ### OpenCode:现状与改进路线 | 已具备的公开能力 | 相对本章目标仍需补强的闭环 | 建议改进方向 | | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | [Tools 文档](https://dev.opencode.ai/docs/tools/) 已公开内建工具、MCP 与权限控制。 | “所有工具默认可用”的便利性并不自动提供 token 级结果预算、截断事实、按需 schema 发现与回写 trace。 | 为每个工具引入结果预算、original-size 与 truncation 字段;把 MCP lazy mode 作为可测试的稳定接口。当前 [mcp-search PR](https://github.com/anomalyco/opencode/pull/12520) 只能视为演进信号。 | ### 适用场景 | 明确结论:优先选 Codex | 明确结论:优先选 CC 2.1.88 | 共同警戒 | | --------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------ | | **高频 CI/测试排障,需控制日志 token 预算时,选 Codex。** | **大量结构化 MCP 查询,需统一包装结果时,选 CC 2.1.88。** | 截断不是完整结果;schema 全量常驻会压垮 prompt。 | ## 6. 运行环境:工具真正在哪儿跑,副作用由谁承接 ### 模块目标 运行环境模块是工具调用真正发生的宿主:PTY、工作目录、文件系统、沙箱、网络通道和 MCP transport 都在这里起作用。输入是已校验、已获准的工具请求;输出是执行结果、错误、审批事件和审计痕迹。核心不变量是:模型和工具协议不能越过宿主边界直接拥有环境副作用。 ### 用人话说 工具模块像下了一张工单,运行环境才是实际施工现场。命令在哪个目录跑、能访问哪些文件、能不能出网、MCP 请求送到哪里,决定了同一张工单最后会发生什么。 ### 常用场景 - 在受限工作目录运行测试、构建或迁移预览。 - 访问外部 API、浏览器自动化或企业 MCP 服务。 - 排查“模型已经请求执行,但为什么被沙箱、端口或环境变量拦住”。 ### 模块机制图 ```mermaid flowchart TD S([开始:① 收到已校验的工具请求]) --> A["绑定工作目录、沙箱与执行策略"] A --> B{"请求类型?"} B -- shell / 文件 --> C["② PTY 或本地执行器"] B -- 网络 --> D["② 按 host、protocol、port 经过审批"] B -- MCP --> E["② MCP client / transport 调用服务"] C --> F["采集退出码、stdout、stderr"] D --> F E --> F F --> G["形成执行事件与原始结果"] G --> H([结束:③ 交给工具模块规范化和写回]) A -. "风险:cwd、沙箱、重定向、服务端副作用" .-> R["环境漂移或越权"] ``` 读图结论:运行环境不是“工具的另一个名字”。工具层管理调用协议与结果回流;运行环境决定请求能否在真实机器、网络和服务端产生副作用。 ### Codex 源码解读 Codex 的运行环境闭环不能只看 PTY 常量:工作请求先携带策略进入执行器,PTY 限制原始输出,网络目标再落到审批事件或真实请求。片段 A 是 [network_approval.rs,第 77–127 行](https://github.com/aimreant/codex-backup/blob/ae057e0bb9f3cde31c54fcca59a78456f7805282/codex-rs/core/src/tools/network_approval.rs#L77-L127),片段 B 是 [pty/src/lib.rs,第 10 行](https://github.com/aimreant/codex-backup/blob/ae057e0bb9f3cde31c54fcca59a78456f7805282/codex-rs/utils/pty/src/lib.rs#L10),片段 C 回到 [tools/context.rs,第 284–362 行](https://github.com/aimreant/codex-backup/blob/ae057e0bb9f3cde31c54fcca59a78456f7805282/codex-rs/core/src/tools/context.rs#L284-L362)(公开源码备份)。 ```rust // 片段 A:读取/准入。网络执行先从 target 构造审批键。 let key = HostApprovalKey { host, protocol, port }; let decision = approval_policy.check(&key); // 片段 B:执行环境。PTY 对单个进程积累的原始输出设硬边界。 pub const DEFAULT_MAX_OUTPUT_BYTES: usize = 1024 * 1024; let pty = spawn_pty(command, cwd, exec_policy).await?; // 片段 C:写回。进程事件和原始输出成为工具层的输入。 let raw_output = pty.collect_output().await?; return ExecCommandToolOutput::from(raw_output); ``` | I/O 项 | 链路 I/O:在受控仓库运行测试 | | ------------ | -------------------------------------------------------------------------------------- | | 输入 | 模型请求在当前工作目录运行测试,命令产生持续 stdout/stderr。 | | 状态变化 | A 检查网络/执行准入;B 以 cwd、策略和 PTY 启动进程;C 将退出事件和原始输出交给工具层。 | | 输出 | 进程退出状态与受限原始输出,随后由工具层按 token 再次治理。 | | 用户可见结果 | 大日志不会无限积累;超出边界时应改为更窄命令。 | | 误判风险 | 1 MiB 是输出缓存边界,不保证测试本身完成,也不等于模型看到的 token 上限。 | **Cookbook 实践旁证(辅助)**:[OpenAI Cookbook 目录](https://developers.openai.com/cookbook) 的 coding、MCP 与工具执行范例强调将外部副作用封装成受控工具调用;没有直接公开 Codex PTY 或其沙箱实现,故本文不把 cookbook 当作环境机制证据。 ### CC 2.1.88 源码解读 CC 2.1.88 的外部运行边界同样可拆成三段:请求进入 MCP Tool、外层权限决定是否允许、client 将获准调用送到 server 并把结果返回。片段 A/C 见 [MCPTool.ts,第 27–75 行](https://github.com/aimreant/claude-code-2.1.88-backup/blob/c8cd253554319f32ff64ff7000636199f720c9bc/source/src/tools/MCPTool/MCPTool.ts#L27-L75),其调用调度上下文见 [runAgent.ts,第 85–217 行](https://github.com/aimreant/claude-code-2.1.88-backup/blob/c8cd253554319f32ff64ff7000636199f720c9bc/source/src/tools/AgentTool/runAgent.ts#L85-L217)(非官方历史快照备份)。 ```ts // 片段 A:读取。Agent 调度将模型选择的工具调用送入 MCP Tool。 const request = { serverName: this.serverName, toolName: this.toolName, input }; // 片段 B:准入。环境调用前仍由外层权限决定。 const permission = await context.canUseTool(this.name, input); if (!permission.allowed) return permission.result; // 片段 C:执行/写回。client 调用 server,结果交回 Tool 层。 const result = await this.client.callTool(request.serverName, request.toolName, input); return formatToolResult(result); ``` | I/O 项 | 链路 I/O:读取企业 MCP 的支付状态 | | ------------ | -------------------------------------------------------------------------------------------------- | | 输入 | 获准的 MCP 工具名、serverName、toolName 和支付查询参数。 | | 状态变化 | A 形成 transport 请求;B 在 client 调用前拒绝或放行;C 返回服务端结果或 transport 错误给 Tool 层。 | | 输出 | 服务端结果或 transport 错误,返回 Tool 层继续规范化。 | | 用户可见结果 | 即使模型会调用工具,服务不可达或服务端拒绝仍会成为可见执行失败。 | | 误判风险 | 这段包装代码不描述 server 内部权限、网络隔离或副作用;那些仍在外部服务边界。 | **当前官方手册旁证(辅助)**:[MCP](https://code.claude.com/docs/en/mcp) 与 [Permissions](https://code.claude.com/docs/en/permissions) 说明当前 MCP 接入与授权体验;本文不从 2.1.88 推断今天的 transport 实现。 ### 实现对比与取舍 | 维度 | Codex | CC 2.1.88 | 直接结论 | | ---------- | -------------------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | | 目标达成 | PTY 与网络审批将本地执行边界建模为可见对象。 | MCP client 将获准请求交给外部服务。 | Codex 适合控制本机 shell 与网络目标;CC 2.1.88 适合接入已有企业服务。 | | 可观察性 | 原始输出预算和网络审批键可在公开源码定位。 | 可看到 permission 到 client 的交接,无法从 client 代码看到服务端实际动作。 | Codex 可审计本机执行条件;CC 还必须从 MCP 服务端日志确认是否读取、写入或触发外部操作。 | | 默认自动化 | 执行器与策略分层,宿主可独立配置。 | MCP 包装统一常规服务调用路径。 | Codex 的配置面更大;CC 降低接入成本,但 MCP 服务端仍需自行实现权限、幂等和回滚。 | | 成本与风险 | 错误的 cwd、沙箱或网络规则会阻断本地任务。 | 服务端可用性、服务端权限和服务端副作用都成为依赖。 | Codex 失败时先查宿主环境与策略;CC 失败时还要查 MCP 服务健康、授权和实际执行记录。 | | 适合任务 | 本地仓库、终端排障、需要精确网络审批。 | 企业服务、知识库与 MCP 集成。 | 生产写操作应先限定目标与权限,再保留可回滚的执行记录。 | ### OpenCode:现状与改进路线 | 已具备的公开能力 | 相对本章目标仍需补强的闭环 | 建议改进方向 | | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | [Tools 文档](https://dev.opencode.ai/docs/tools/) 公开 shell、文件、MCP 等执行面,Agents 文档公开对 bash/MCP 的权限控制。 | 工具权限、cwd、子进程、网络目标、服务端副作用若分散在配置与工具实现中,用户难以得到一条统一执行 trace。 | 将每次执行写成 capability trace:工作目录、策略判定、实际命令/目标、退出码、输出预算、外部服务结果一并记录;再逐步把高风险网络操作收窄到目标级能力。 | ### 适用场景 | 明确结论:优先选 Codex | 明确结论:优先选 CC 2.1.88 | 共同警戒 | | -------------------------------------------------------- | --------------------------------------------------------- | -------------------------------------------------------- | | **本地 shell、网络目标和输出预算都要审计时,选 Codex。** | **主要依赖企业 MCP 与统一 client 调用时,选 CC 2.1.88。** | 检查 cwd、沙箱、重定向和服务端写副作用;失败别盲目重试。 | ## 从地图回到选择:任务形状决定 Harness 策略 六要素的差异只有放回任务链路才有意义:流程决定是否隔离探索;约束决定哪些计划根本不可执行;持久化决定长任务还能记住什么;上下文决定模型当轮能推理什么;工具决定证据怎样回流;运行环境决定副作用最终在哪发生。 | 任务形状 | 更偏向的选择 | 原因 | | -------------------------------------- | ------------------------------ | ---------------------------------------------------- | | 长日志排障,必须解释截断、压缩和重试 | Codex | 工具输出、compact 退避、PTY 和审批均有公开源码落点。 | | 首次进入大仓库,只需只读摸清模块 | CC 2.1.88 的 Explore 思路 | 默认隔离、只读工具与模型路由降低主会话噪声。 | | 跨文件改动,子任务必须理解已有架构决策 | Codex | New/继承 history 与执行策略是显式编排参数。 | | 带附件的长研究任务 | CC 2.1.88 的 post-compact 思路 | boundary、摘要、保留消息与附件分层重建。 | | 高风险生产网络访问 | Codex | host、protocol、port 给出更具体的审批对象。 | | 团队主要使用企业 MCP | CC 的 MCP 包装思路 | 统一权限判断后转交 MCP client,便于集中治理。 | 最短的结论仍然是:Codex 把更多 Harness 开关留给宿主,因此更适合强调审计、编排和环境治理的工程团队;CC 2.1.88 把更多常见策略封装成默认 runtime,因此更适合先隔离探索、再回收结论的低摩擦工作流。二者都不能替代对规则、关键证据与外部副作用的人工复核。 ## OpenCode 的三阶段改进路线 这里的“改进”只针对本文选取的 Harness 保证,不比较模型质量、生态规模或产品偏好。 | 阶段 | 先补的闭环 | 可验证的完成标准 | | ----------- | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | 1. 正确性 | 压缩后重注入 durable rules;把权限从 prompt 提示延伸到运行时决策。 | 每次 compact 都能复查保留/丢弃对象;高风险工具有明确 allow、ask、deny 事件。 | | 2. 可观察性 | 为 context、工具输出和执行环境写入统一 trace。 | 能回答“本轮哪些规则、history、schema 入窗”“结果是否截断”“命令/服务实际执行了什么”。 | | 3. 协作性 | 为 Task 增加 history mode、结构化结果与父线程 join,再谨慎引入并行。 | 子任务可独立复现,父线程只接收约定产物,并发不会扩大权限或让结果无序回灌。 | 这条路线的收益不是“功能更多”,而是把现有工具、session、权限和 MCP 连接成可验证闭环;任何尚未合入的 PR 或 Issue 都只能作为候选方向。 ## 附录 A:六要素证据矩阵 | 要素 | Codex 源码链 | CC 2.1.88 源码链 | OpenCode 固定源码 | Cookbook | CC 手册 | OpenCode 文档 | 证据限制 | | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -------------------------------------------------- | ---------------------------------------------- | | 流程 | [delegate A](https://github.com/aimreant/codex-backup/blob/ae057e0bb9f3cde31c54fcca59a78456f7805282/codex-rs/core/src/codex_delegate.rs#L63-L98) + [B/C](https://github.com/aimreant/codex-backup/blob/ae057e0bb9f3cde31c54fcca59a78456f7805282/codex-rs/core/src/codex_delegate.rs#L105-L138) | [runAgent](https://github.com/aimreant/claude-code-2.1.88-backup/blob/c8cd253554319f32ff64ff7000636199f720c9bc/source/src/tools/AgentTool/runAgent.ts#L85-L217) + [Explore](https://github.com/aimreant/claude-code-2.1.88-backup/blob/c8cd253554319f32ff64ff7000636199f720c9bc/source/src/tools/AgentTool/built-in/exploreAgent.ts#L24-L56) | [release](https://github.com/aimreant/opencode-backup/tree/d2bd7ea) | 辅助:[Orchestrating agents](https://cookbook.openai.com/examples/orchestrating_agents) | 辅助:[Subagents](https://code.claude.com/docs/en/sub-agents) | 辅助:[Agents](https://opencode.ai/docs/agents/) | OpenCode Issue 只作路线信号。 | | 约束 | [context](https://github.com/aimreant/codex-backup/blob/ae057e0bb9f3cde31c54fcca59a78456f7805282/codex-rs/core/src/compact.rs#L191-L223) + [approval](https://github.com/aimreant/codex-backup/blob/ae057e0bb9f3cde31c54fcca59a78456f7805282/codex-rs/core/src/tools/network_approval.rs#L77-L127) | [context](https://github.com/aimreant/claude-code-2.1.88-backup/blob/c8cd253554319f32ff64ff7000636199f720c9bc/source/src/context.ts#L152-L188) + [MCPTool](https://github.com/aimreant/claude-code-2.1.88-backup/blob/c8cd253554319f32ff64ff7000636199f720c9bc/source/src/tools/MCPTool/MCPTool.ts#L27-L75) | [release](https://github.com/aimreant/opencode-backup/tree/d2bd7ea) | 辅助:[Cookbook](https://developers.openai.com/cookbook) | 辅助:[Permissions](https://code.claude.com/docs/en/permissions) | 辅助:[Agents](https://opencode.ai/docs/agents/) | Cookbook 不证明审批键。 | | 持久化 | [compact A/C](https://github.com/aimreant/codex-backup/blob/ae057e0bb9f3cde31c54fcca59a78456f7805282/codex-rs/core/src/compact.rs#L191-L223) + [B](https://github.com/aimreant/codex-backup/blob/ae057e0bb9f3cde31c54fcca59a78456f7805282/codex-rs/core/src/compact.rs#L154-L230) | [compact A/C](https://github.com/aimreant/claude-code-2.1.88-backup/blob/c8cd253554319f32ff64ff7000636199f720c9bc/source/src/services/compact/compact.ts#L325-L334) + [B](https://github.com/aimreant/claude-code-2.1.88-backup/blob/c8cd253554319f32ff64ff7000636199f720c9bc/source/src/services/compact/compact.ts#L596-L610) | [compaction](https://github.com/aimreant/opencode-backup/blob/d2bd7ea/packages/opencode/src/session/compaction.ts) | 辅助:[Cookbook](https://developers.openai.com/cookbook) | 辅助:[Context window](https://code.claude.com/docs/en/context-window) | 辅助:[Agents](https://opencode.ai/docs/agents/) | CC 为历史;OpenCode 压缩规则需按 commit 复核。 | | 上下文 | [context A/B](https://github.com/aimreant/codex-backup/blob/ae057e0bb9f3cde31c54fcca59a78456f7805282/codex-rs/core/src/compact.rs#L191-L223) + [search](https://github.com/aimreant/codex-backup/blob/ae057e0bb9f3cde31c54fcca59a78456f7805282/codex-rs/core/src/tools/handlers/tool_search.rs) | [context](https://github.com/aimreant/claude-code-2.1.88-backup/blob/c8cd253554319f32ff64ff7000636199f720c9bc/source/src/context.ts#L152-L188) + [search](https://github.com/aimreant/claude-code-2.1.88-backup/blob/c8cd253554319f32ff64ff7000636199f720c9bc/source/src/tools/ToolSearchTool/ToolSearchTool.ts#L21-L45) | [release](https://github.com/aimreant/opencode-backup/tree/d2bd7ea) | 辅助:[Cookbook](https://developers.openai.com/cookbook) | 辅助:[How it works](https://code.claude.com/docs/en/how-claude-code-works) | 辅助:[Tools](https://dev.opencode.ai/docs/tools/) | 文档不等同于 prompt 组装代码。 | | 工具 | [search](https://github.com/aimreant/codex-backup/blob/ae057e0bb9f3cde31c54fcca59a78456f7805282/codex-rs/core/src/tools/handlers/tool_search.rs) + [result](https://github.com/aimreant/codex-backup/blob/ae057e0bb9f3cde31c54fcca59a78456f7805282/codex-rs/core/src/tools/context.rs#L284-L362) | [search](https://github.com/aimreant/claude-code-2.1.88-backup/blob/c8cd253554319f32ff64ff7000636199f720c9bc/source/src/tools/ToolSearchTool/ToolSearchTool.ts#L21-L45) + [MCP](https://github.com/aimreant/claude-code-2.1.88-backup/blob/c8cd253554319f32ff64ff7000636199f720c9bc/source/src/tools/MCPTool/MCPTool.ts#L27-L75) | [release](https://github.com/aimreant/opencode-backup/tree/d2bd7ea) | 辅助:[Cookbook](https://developers.openai.com/cookbook) | 辅助:[Tools](https://code.claude.com/docs/en/tools-reference) | 辅助:[Tools](https://dev.opencode.ai/docs/tools/) | lazy MCP PR 不是稳定能力。 | | 运行环境 | [approval](https://github.com/aimreant/codex-backup/blob/ae057e0bb9f3cde31c54fcca59a78456f7805282/codex-rs/core/src/tools/network_approval.rs#L77-L127) + [PTY](https://github.com/aimreant/codex-backup/blob/ae057e0bb9f3cde31c54fcca59a78456f7805282/codex-rs/utils/pty/src/lib.rs#L10) | [MCP](https://github.com/aimreant/claude-code-2.1.88-backup/blob/c8cd253554319f32ff64ff7000636199f720c9bc/source/src/tools/MCPTool/MCPTool.ts#L27-L75) + [run](https://github.com/aimreant/claude-code-2.1.88-backup/blob/c8cd253554319f32ff64ff7000636199f720c9bc/source/src/tools/AgentTool/runAgent.ts#L85-L217) | [release](https://github.com/aimreant/opencode-backup/tree/d2bd7ea) | 辅助:[Cookbook](https://developers.openai.com/cookbook) | 辅助:[MCP](https://code.claude.com/docs/en/mcp) | 辅助:[Tools](https://dev.opencode.ai/docs/tools/) | 外部服务边界需另行审计。 | ## 附录 B:怎样正确使用四层证据 | 想回答的问题 | 应优先引用 | 不应做的推断 | | -------------------------------------------- | ----------------------- | ----------------------------------------- | | 某变量、阈值、分支、history 对象如何变化? | 固定 commit 的源码 | 用 Cookbook 或产品文档猜内部实现。 | | 开发者应如何拆任务、收窄工具输出、设计回传? | Cookbook 与章节场景建议 | 把 cookbook 模式当作 Codex 的运行时承诺。 | | 今天 Claude Code 对用户提供什么能力? | Claude Code 官方手册 | 用 2.1.88 镜像证明当前默认配置。 | | 某个历史 CC 内部路径怎样工作? | 2.1.88 镜像,附历史标签 | 把该路径表述为 Anthropic 当前官方实现。 | - 每段代码只解释紧邻片段直接读取、写入或分支的对象;未展示部分不作确定断言。 - 文中的 I/O 例子是从相邻代码可直接推演的最小案例,不是性能基准或 UI 录屏。 - 若决策依赖当前 Claude Code 行为,先复查官方手册与实际安装版本;若依赖 Codex 实现,使用本文固定 commit,而非未来变化的 main 分支。
https://www.mkshell.com/posts/2023-07-24-how-to-ask-questions-the-smart-way/ https://www.mkshell.com/posts/2023-07-24-how-to-ask-questions-the-smart-way.md 2023-07-24 # 提问的智慧 > How-To-Ask-Questions-The-Smart-Way 《提问的智慧》 ## 目录 - [声明](#声明) - [简介](#简介) - [在提问之前](#在提问之前) - [当你提问时](#当你提问时) - [慎选提问的论坛](#慎选提问的论坛) - [Stack Overflow](#stack-overflow) - [网站和 IRC 论坛](#网站和-irc-论坛) - [第二步,使用项目邮件列表](#第二步使用项目邮件列表) - [使用有意义且描述明确的标题](#使用有意义且描述明确的标题) - [使问题容易回复](#使问题容易回复) - [使用清晰、正确、精准且合乎语法的语句](#使用清晰、正确、精准且合乎语法的语句) - [使用易于读取且标准的文件格式发送问题](#使用易于读取且标准的文件格式发送问题) - [精确地描述问题并言之有物](#精确地描述问题并言之有物) - [话不在多而在精](#话不在多而在精) - [别动辄声称找到 Bug](#别动辄声称找到-bug) - [低声下气不能代替你的功课](#低声下气不能代替你的功课) - [描述问题症状而非你的猜测](#描述问题症状而非你的猜测) - [按发生时间先后列出问题症状](#按发生时间先后列出问题症状) - [描述目标而不是过程](#描述目标而不是过程) - [别要求使用私人电邮回复](#别要求使用私人电邮回复) - [清楚明确的表达你的问题以及需求](#清楚明确的表达你的问题以及需求) - [询问有关代码的问题时](#询问有关代码的问题时) - [别把自己家庭作业的问题贴上来](#别把自己家庭作业的问题贴上来) - [去掉无意义的提问句](#去掉无意义的提问句) - [即使你很急也不要在标题写`紧急`](#即使你很急也不要在标题写紧急) - [礼多人不怪,而且有时还很有帮助](#礼多人不怪而且有时还很有帮助) - [问题解决后,加个简短的补充说明](#问题解决后加个简短的补充说明) - [如何解读答案](#如何解读答案) - [RTFM 和 STFW:如何知道你已完全搞砸了](#rtfm-和-stfw如何知道你已完全搞砸了) - [如果还是搞不懂](#如果还是搞不懂) - [处理无礼的回应](#处理无礼的回应) - [如何避免扮演失败者](#如何避免扮演失败者) - [不该问的问题](#不该问的问题) - [好问题与蠢问题](#好问题与蠢问题) - [如果得不到回答](#如果得不到回答) - [如何更好地回答问题](#如何更好地回答问题) - [相关资源](#相关资源) - [鸣谢](#鸣谢) ## 声明 许多项目在他们网站的帮助文档中链接了本指南。这很好,这正是我们想要的用途。但如果你是该项目管理员并试图创建指向本指南的超链接,请在超链接附近的显著位置注明: **本指南不提供此项目的实际支持服务!** 我们已经深刻领教到缺少上述声明所带来的痛苦:我们将不停地被那些认为发布这本指南就意味着有责任解决世上所有技术问题的傻瓜苦苦纠缠。 如果你因寻求某些帮助而阅读本指南,并在离开时还觉得可以从本文作者这里得到直接帮助,那你就是我们之前说的那些傻瓜之一。别问我们问题,我们只会忽略你。我们在这本指南中想教你如何从那些真正懂得你所遇到的软件或硬件问题的人处取得协助,而 99% 的情况下那不会是我们。除非你确定本指南的作者之一刚好是你所遇到的问题领域的专家,否则请不要打扰我们,这样大家都会开心一点。 ## 简介 在[黑客](http://www.catb.org/~esr/faqs/hacker-howto.html)的世界里,当你拋出一个技术问题时,最终是否能得到有用的回答,往往取决于你所提问和追问的方式。本指南将教你如何正确地提问以获得你满意的答案。 现在开源(Open Source)软件已经相当盛行,您通常可以从其他更有经验的用户那里获得与黑客一样好的答案,这是件**好事**;和黑客相比,用户们往往对那些新手常遇到的问题更宽容一些。尽管如此,以我们在此推荐的方式对待这些有经验的用户通常也是从他们那里获得有用答案的最有效方式。 首先你应该明白,黑客们喜爱有挑战性的问题,或者能激发他们思维的好问题。如果我们并非如此,那我们也不会成为你想询问的对象。如果你给了我们一个值得反复咀嚼玩味的好问题,我们自会对你感激不尽。好问题是激励,是厚礼。好问题可以提高我们的理解力,而且通常会暴露我们以前从没意识到或者思考过的问题。对黑客而言,“好问题!”是诚挚的大力称赞。 尽管如此,黑客们有着蔑视或傲慢面对简单问题的坏名声,这有时让我们看起来对新手、无知者似乎较有敌意,但其实不是那样的。 我们不讳言我们对那些不愿思考、或者在发问前不做他们该做的事的人的蔑视。那些人是时间杀手 —— 他们只想索取,从不付出,消耗我们可用在更有趣的问题或更值得回答的人身上的时间。我们称这样的人为 `失败者(撸瑟)` (由于历史原因,我们有时把它拼作 `lusers`)。 我们意识到许多人只是想使用我们写的软件,他们对学习技术细节没有兴趣。对大多数人而言,电脑只是种工具,是种达到目的的手段而已。他们有自己的生活并且有更要紧的事要做。我们认可这点,也从不指望每个人都对这些让我们着迷的技术问题感兴趣。尽管如此,我们只为那些真正有兴趣并愿意积极参与问题解决的人调整回答问题的风格。这点不会变,也不该变:否则,我们就是在最擅长的事情上降低效率。 我们(在很大程度上)是自愿的,从繁忙的生活中抽出时间来解答疑惑,而且时常被提问淹没。所以我们无情地滤掉一些话题,特别是拋弃那些看起来像失败者的家伙,以便更高效地利用时间来回答`赢家(winner)`的问题。 如果你厌恶我们的态度,高高在上,或过于傲慢,不妨也设身处地想想。我们并没有要求你向我们屈服 —— 事实上,我们大多数人非常乐意与你平等地交流,只要你付出小小努力来满足基本要求,我们就会欢迎你加入我们的文化。但让我们帮助那些不愿意帮助自己的人是没有效率的。无知没有关系,但装白痴就是不行。 所以,你不必在技术上很在行才能吸引我们的注意,但你必须表现出能引导你变得在行的特质 —— 机敏、有想法、善于观察、乐于主动参与解决问题。如果你做不到这些使你与众不同的事情,我们建议你花点钱找家商业公司签个技术支持服务合同,而不是要求黑客个人无偿地帮助你。 如果你决定向我们求助,当然你也不希望被视为失败者,更不愿成为失败者中的一员。能立刻得到快速并有效答案的最好方法,就是像赢家那样提问 —— 聪明、自信、有解决问题的思路,只是偶尔在特定的问题上需要获得一点帮助。 (欢迎对本指南提出改进意见。你可以把你的建议发送至 [esr@thyrsus.com](esr@thyrsus.com) 或 [respond-auto@linuxmafia.com](respond-auto@linuxmafia.com)。然而请注意,本文并非[网络礼节](http://www.ietf.org/rfc/rfc1855.txt)的通用指南,而我们通常会拒绝无助于在技术论坛得到有用答案的建议)。 ## 在提问之前 在你准备要通过电子邮件、新闻群组或者聊天室提出技术问题前,请先做到以下事情: 1. 尝试在你准备提问的论坛的旧文章中搜索答案。 2. 尝试上网搜索以找到答案。 3. 尝试阅读手册以找到答案。 4. 尝试阅读常见问题文件(FAQ)以找到答案。 5. 尝试自己检查或试验以找到答案。 6. 向你身边的强者朋友打听以找到答案。 7. 如果你是程序开发者,请尝试阅读源代码以找到答案。 当你提出问题的时候,请先表明你已经做了上述的努力;这将有助于树立你并不是一个不劳而获且浪费别人的时间的提问者。如果你能一并表达在做了上述努力的过程中所**学到**的东西会更好,因为我们更乐于回答那些表现出能从答案中学习的人的问题。 运用某些策略,比如先用 Google 搜索你所遇到的各种错误信息(搜索 [Google 论坛](http://groups.google.com/)和网页),这样很可能直接就找到了能解决问题的文件或邮件列表线索。即使没有结果,在邮件列表或新闻组寻求帮助时加上一句 `我在 Google 中搜过下列句子但没有找到什么有用的东西` 也是件好事,即使它只是表明了搜索引擎不能提供哪些帮助。这么做(加上搜索过的字串)也让遇到相似问题的其他人能被搜索引擎引导到你的提问来。 别着急,不要指望几秒钟的 Google 搜索就能解决一个复杂的问题。在向专家求助之前,再阅读一下常见问题文件(FAQ)、放轻松、坐得舒服一些,再花点时间思考一下这个问题。相信我们,他们能从你的提问看出你做了多少阅读与思考,如果你是有备而来,将更有可能得到解答。不要将所有问题一股脑拋出,只因你的第一次搜索没有找到答案(或者找到太多答案)。 准备好你的问题,再将问题仔细地思考过一遍,因为草率的发问只能得到草率的回答,或者根本得不到任何答案。越是能表现出在寻求帮助前你为解决问题所付出的努力,你越有可能得到实质性的帮助。 小心别问错了问题。如果你的问题基于错误的假设,某个普通黑客(J. Random Hacker)多半会一边在心里想着`蠢问题…`,一边用无意义的字面解释来答复你,希望着你会从问题的回答(而非你想得到的答案)中汲取教训。 绝不要自以为**够格**得到答案,你没有;你并没有。毕竟你没有为这种服务支付任何报酬。你将会是自己去**挣到**一个答案,靠提出有内涵的、有趣的、有思维激励作用的问题 —— 一个有潜力能贡献社区经验的问题,而不仅仅是被动地从他人处索取知识。 另一方面,表明你愿意在找答案的过程中做点什么是一个非常好的开端。`谁能给点提示?`、`我的这个例子里缺了什么?`以及`我应该检查什么地方`比`请把我需要的确切的过程贴出来`更容易得到答复。因为你表现出只要有人能指个正确方向,你就有完成它的能力和决心。 ## 当你提问时 ### 慎选提问的论坛 小心选择你要提问的场合。如果你做了下述的事情,你很可能被忽略掉或者被看作失败者: - 在与主题不合的论坛上贴出你的问题。 - 在探讨进阶技术问题的论坛张贴非常初级的问题;反之亦然。 - 在太多的不同新闻群组上重复转贴同样的问题(cross-post)。 - 向既非熟人也没有义务解决你问题的人发送私人电邮。 黑客会剔除掉那些搞错场合的问题,以保护他们沟通的渠道不被无关的东西淹没。你不会想让这种事发生在自己身上的。 因此,第一步是找到对的论坛。再说一次,Google 和其它搜索引擎还是你的朋友,用它们来找到与你遭遇到困难的软硬件问题最相关的网站。通常那儿都有常见问题(FAQ)、邮件列表及相关说明文件的链接。如果你的努力(包括**阅读** FAQ)都没有结果,网站上也许还有报告 Bug(Bug-reporting)的流程或链接,如果是这样,链过去看看。 向陌生的人或论坛发送邮件最可能是风险最大的事情。举例来说,别假设一个提供丰富内容的网页的作者会想充当你的免费顾问。不要对你的问题是否会受到欢迎做太乐观的估计 —— 如果你不确定,那就向别处发送,或者压根别发。 在选择论坛、新闻群组或邮件列表时,别太相信它的名字,先看看 FAQ 或者许可书以弄清楚你的问题是否切题。发文前先翻翻已有的话题,这样可以让你感受一下那里的文化。事实上,事先在新闻组或邮件列表的历史记录中搜索与你问题相关的关键词是个极好的主意,也许这样就找到答案了。即使没有,也能帮助你归纳出更好的问题。 别像机关枪似的一次“扫射”所有的帮助渠道,这就像大喊大叫一样会使人不快。要一个一个地来。 搞清楚你的主题!最典型的错误之一是在某种致力于跨平台可移植的语言、套件或工具的论坛中提关于 Unix 或 Windows 操作系统程序界面的问题。如果你不明白为什么这是大错,最好在搞清楚这之间差异之前什么也别问。 一般来说,在仔细挑选的公共论坛中提问,会比在私有论坛中提同样的问题更容易得到有用的回答。有几个理由可以支持这点,一是看潜在的回复者有多少,二是看观众有多少。黑客较愿意回答那些能帮助到许多人的问题。 可以理解的是,老练的黑客和一些热门软件的作者正在接受过多的错发信息。就像那根最后压垮骆驼背的稻草一样,你的加入也有可能使情况走向极端 —— 已经好几次了,一些热门软件的作者由于涌入其私人邮箱的大量不堪忍受的无用邮件而不再提供支持。 ### Stack Overflow 搜索,*然后*在 Stack Exchange 问。 近年来,Stack Exchange 社区已经成为回答技术及其他问题的主要渠道,尤其是那些开放源码的项目。 因为 Google 索引是即时的,在看 Stack Exchange 之前先在 Google 搜索。有很高的几率某人已经问了一个类似的问题,而且 Stack Exchange 网站们往往会是搜索结果中最前面几个。如果你在 Google 上没有找到任何答案,你再到特定相关主题的网站去找。用标签(Tag)搜索能让你更缩小你的搜索结果。 如果你还是找不到任何对你的问题有用的内容,请把你的问题发在与它最相关的网站上。提问的时候请善用格式化工具,尤其注意为代码添加格式,并且添加相关的标签(特别是编程语言、操作系统或库/包的名称)。当有人要求你提供更多相关信息时,请编辑你的贴子来补充它们\[译注:而不是发一个回帖或回答!\]。如果你觉得一个答案对你有帮助,点击向上的箭头来为它投票;如果一个答案提供了问题的正确解决方案,点击投票按钮下方的对勾来将它标记为正解。 Stack Exchange 已经成长到[超过一百个网站](https://stackexchange.com/sites),以下是最常用的几个站: - Super User 是问一些通用的电脑问题,如果你的问题跟代码或是写程序无关,只是一些网络连线之类的,请到这里。 - Stack Overflow 是问写程序有关的问题。 - Server Fault 是问服务器和网管相关的问题。 ### 网站和 IRC 论坛 本地的用户群组(user group),或者你所用的 Linux 发行版本也许正在宣传他们的网页论坛或 IRC 频道,并提供新手帮助(在一些非英语国家,新手论坛很可能还是邮件列表),这些都是开始提问的好地方,特别是当你觉得遇到的也许只是相对简单或者很普通的问题时。有广告赞助的 IRC 频道是公开欢迎提问的地方,通常可以即时得到回应。 事实上,如果程序出的问题只发生在特定 Linux 发行版提供的版本(这很常见),最好先去该发行版的论坛或邮件列表中提问,再到程序本身的论坛或邮件列表提问。(否则)该项目的黑客可能仅仅回复“使用**我们的**版本”。 在任何论坛发文以前,先确认一下有没有搜索功能。如果有,就试着搜索一下问题的几个关键词,也许这会有帮助。如果在此之前你已做过通用的网页搜索(你也该这样做),还是再搜索一下论坛,搜索引擎有可能没来得及索引此论坛的全部内容。 通过论坛或 IRC 频道来提供用户支持服务有增长的趋势,电子邮件则大多为项目开发者间的交流而保留。所以最好先在论坛或 IRC 中寻求与该项目相关的协助。 在使用 IRC 的时候,首先最好不要发布很长的问题描述,有些人称之为频道洪水。最好通过一句话的问题描述来开始聊天。 ### 第二步,使用项目邮件列表 当某个项目提供开发者邮件列表时,要向列表而不是其中的个别成员提问,即使你确信他能最好地回答你的问题。查一查项目的文件和首页,找到项目的邮件列表并使用它。有几个很好的理由支持我们采用这种办法: - 任何好到需要向个别开发者提出的问题,也将对整个项目群组有益。反之,如果你认为自己的问题对整个项目群组来说太愚蠢,那这也不能成为骚扰个别开发者的理由。 - 向列表提问可以分散开发者的负担,个别开发者(尤其是项目领导人)也许太忙以至于没法回答你的问题。 - 大多数邮件列表都会被存档,那些被存档的内容将被搜索引擎索引。如果你向列表提问并得到解答,将来其他人可以通过网页搜索找到你的问题和答案,也就不用再次发问了。 - 如果某些问题经常被问到,开发者可以利用此信息来改进说明文件或软件本身,以使其更清楚。如果只是私下提问,就没有人能看到最常见问题的完整场景。 如果一个项目既有“用户”也有“开发者”(或“黑客”)邮件列表或论坛,而你又不会动到那些源代码,那么就向“用户”列表或论坛提问。不要假设自己会在开发者列表中受到欢迎,那些人多半会将你的提问视为干扰他们开发的噪音。 然而,如果你**确信**你的问题很特别,而且在“用户”列表或论坛中几天都没有回复,可以试试前往“开发者”列表或论坛发问。建议你在张贴前最好先暗地里观察几天以了解那里的行事方式(事实上这是参与任何私有或半私有列表的好主意) 如果你找不到一个项目的邮件列表,而只能查到项目维护者的电子邮件地址,尽管向他发信。即使是在这种情况下,也别假设(项目)邮件列表不存在。在你的电子邮件中,请陈述你已经试过但没有找到合适的邮件列表,也提及你不反对将自己的邮件转发给他人(许多人认为,即使没什么秘密,私人电子邮件也不应该被公开。通过允许将你的电子邮件转发他人,你给了相应人员处置你邮件的选择)。 ### 使用有意义且描述明确的标题 在邮件列表、新闻群组或论坛中,大约 50 字以内的标题是抓住资深专家注意力的好机会。别用喋喋不休的`帮帮忙`、`跪求`、`急`(更别说`救命啊!!!!`这样让人反感的话,用这种标题会被条件反射式地忽略)来浪费这个机会。不要妄想用你的痛苦程度来打动我们,而应该是在这点空间中使用极简单扼要的描述方式来提出问题。 一个好标题范例是`目标 —— 差异`式的描述,许多技术支持组织就是这样做的。在`目标`部分指出是哪一个或哪一组东西有问题,在`差异`部分则描述与期望的行为不一致的地方。 > 蠢问题:救命啊!我的笔记本电脑不能正常显示了! 聪明问题:X.org 6.8.1 的鼠标指针会变形,某牌显卡 MV1005 芯片组。 更聪明问题:X.org 6.8.1 的鼠标指针,在某牌显卡 MV1005 芯片组环境下 - 会变形。 编写`目标 —— 差异` 式描述的过程有助于你组织对问题的细致思考。是什么被影响了? 仅仅是鼠标指针或者还有其它图形?只在 X.org 的 X 版中出现?或只是出现在 6.8.1 版中? 是针对某牌显卡芯片组?或者只是其中的 MV1005 型号? 一个黑客只需瞄一眼就能够立即明白你的环境**和**你遇到的问题。 总而言之,请想像一下你正在一个只显示标题的存档讨论串(Thread)索引中查寻。让你的标题更好地反映问题,可使下一个搜索类似问题的人能够关注这个讨论串,而不用再次提问相同的问题。 如果你想在回复中提出问题,记得要修改内容标题,以表明你是在问一个问题, 一个看起来像 `Re: 测试` 或者 `Re: 新 bug` 的标题很难引起足够重视。另外,在不影响连贯性之下,适当引用并删减前文的内容,能给新来的读者留下线索。 对于讨论串,不要直接点击回复来开始一个全新的讨论串,这将限制你的观众。因为有些邮件阅读程序,比如 mutt ,允许用户按讨论串排序并通过折叠讨论串来隐藏消息,这样做的人永远看不到你发的消息。 仅仅改变标题还不够。mutt 和其它一些邮件阅读程序还会检查邮件标题以外的其它信息,以便为其指定讨论串。所以宁可发一个全新的邮件。 在网页论坛上,好的提问方式稍有不同,因为讨论串与特定的信息紧密结合,并且通常在讨论串外就看不到里面的内容,故通过回复提问,而非改变标题是可接受的。不是所有论坛都允许在回复中出现分离的标题,而且这样做了基本上没有人会去看。不过,通过回复提问,这本身就是暧昧的做法,因为它们只会被正在查看该标题的人读到。所以,除非你**只想**在该讨论串当前活跃的人群中提问,不然还是另起炉灶比较好。 ### 使问题容易回复 以`请将你的回复发送到……`来结束你的问题多半会使你得不到回答。如果你觉得花几秒钟在邮件客户端设置一下回复地址都麻烦,我们也觉得花几秒钟思考你的问题更麻烦。如果你的邮件程序不支持这样做,[换个好点的](http://linuxmafia.com/faq/Mail/muas.html);如果是操作系统不支持这种邮件程序,也换个好点的。 在论坛,要求通过电子邮件回复是非常无礼的,除非你认为回复的信息可能比较敏感(有人会为了某些未知的原因,只让你而不是整个论坛知道答案)。如果你只是想在有人回复讨论串时得到电子邮件提醒,可以要求网页论坛发送给你。几乎所有论坛都支持诸如`追踪此讨论串`、`有回复时发送邮件提醒`等功能。 ### 使用清晰、正确、精准且合乎语法的语句 我们从经验中发现,粗心的提问者通常也会粗心地写程序与思考(我敢打包票)。回答粗心大意者的问题很不值得,我们宁愿把时间耗在别处。 正确的拼写、标点符号和大小写是很重要的。一般来说,如果你觉得这样做很麻烦,不想在乎这些,那我们也觉得麻烦,不想在乎你的提问。花点额外的精力斟酌一下字句,用不着太僵硬与正式 —— 事实上,黑客文化很看重能准确地使用非正式、俚语和幽默的语句。但它**必须很**准确,而且有迹象表明你是在思考和关注问题。 正确地拼写、使用标点和大小写,不要将`its`混淆为`it's`,`loose`搞成`lose`或者将`discrete`弄成`discreet`。不要**全部用大写**,这会被视为无礼的大声嚷嚷(全部小写也好不到哪去,因为不易阅读。[Alan Cox](http://en.wikipedia.org/wiki/Alan_Cox) 也许可以这样做,但你不行)。 更白话的说,如果你写得像是个半文盲\[译注:[小白](http://zh.wikipedia.org/wiki/小白)\],那多半得不到理睬。也不要使用即时通信中的简写或[火星文](http://zh.wikipedia.org/wiki/火星文),如将`的`简化为`d`会使你看起来像一个为了少打几个键而省字的小白。更糟的是,如果像个小孩似地鬼画符那绝对是在找死,可以肯定没人会理你(或者最多是给你一大堆指责与挖苦)。 如果在使用非母语的论坛提问,你可以犯点拼写和语法上的小错,但决不能在思考上马虎(没错,我们通常能弄清两者的分别)。同时,除非你知道回复者使用的语言,否则请使用英语书写。繁忙的黑客一般会直接删除用他们看不懂的语言写的消息。在网络上英语是通用语言,用英语书写可以将你的问题在尚未被阅读就被直接删除的可能性降到最低。 如果英文是你的外语(Second language),提示潜在回复者你有潜在的语言困难是很好的: \[译注:以下附上原文以供使用\] > English is not my native language; please excuse typing errors. - 英文不是我的母语,请原谅我的错字或语法。 > If you speak $LANGUAGE, please email/PM me; I may need assistance translating my question. - 如果你说**某语言**,请向我发电邮/私信; - 我需要有人协助我翻译我的问题。 > I am familiar with the technical terms, but some slang expressions and idioms are difficult for me. - 我对技术名词很熟悉,但对于俗语或是特别用法不甚了解。 > I've posted my question in $LANGUAGE and English. I'll be glad to translate responses, if you only use one or the other. - 我把我的问题用**某语言**和英文写出来。 - 如果你只用其中的一种语言回答,我会乐意将回复翻译成为你使用的语言。 ### 使用易于读取且标准的文件格式发送问题 如果你人为地将问题搞得难以阅读,它多半会被忽略,人们更愿读易懂的问题,所以: - 使用纯文字而不是 HTML ([关闭 HTML](http://archive.birdhouse.org/etc/evilmail.html) 并不难)。 - 使用 MIME 附件通常是可以的,前提是真正有内容(譬如附带的源代码或 patch),而不仅仅是邮件程序生成的模板(譬如只是信件内容的拷贝)。 - 不要发送一段文字只是一行句子但自动换行后会变成多行的邮件(这使得回复部分内容非常困难)。设想你的读者是在 80 个字符宽的终端机上阅读邮件,最好设置你的换行分割点小于 80 字。 - 但是,对一些特殊的文件**不要**设置固定宽度(譬如日志文件拷贝或会话记录)。数据应该原样包含,让回复者有信心他们看到的是和你看到的一样的东西。 - 在英语论坛中,不要使用`Quoted-Printable` MIME 编码发送消息。这种编码对于张贴非 ASCII 语言可能是必须的,但很多邮件程序并不支持这种编码。当它们处理换行时,那些文本中四处散布的`=20`符号既难看也分散注意力,甚至有可能破坏内容的语意。 - 绝对,**永远**不要指望黑客们阅读使用封闭格式编写的文档,像微软公司的 Word 或 Excel 文件等。大多数黑客对此的反应就像有人将还在冒热气的猪粪倒在你家门口时你的反应一样。即便他们能够处理,他们也很厌恶这么做。 - 如果你从使用 Windows 的电脑发送电子邮件,关闭微软愚蠢的`智能引号`功能 (从\[选项\] > \[校订\] > \[自动校正选项\],勾选掉`智能引号`单选框),以免在你的邮件中到处散布垃圾字符。 - 在论坛,勿滥用`表情符号`和`HTML`功能(当它们提供时)。一两个表情符号通常没有问题,但花哨的彩色文本倾向于使人认为你是个无能之辈。过滥地使用表情符号、色彩和字体会使你看来像个傻笑的小姑娘。这通常不是个好主意,除非你只是对性而不是对答案感兴趣。 如果你使用图形用户界面的邮件程序(如微软公司的 Outlook 或者其它类似的),注意它们的默认设置不一定满足这些要求。大多数这类程序有基于选单的`查看源代码`命令,用它来检查发送文件夹中的邮件,以确保发送的是纯文本文件同时没有一些奇怪的字符。 ### 精确地描述问题并言之有物 - 仔细、清楚地描述你的问题或 Bug 的症状。 - 描述问题发生的环境(机器配置、操作系统、应用程序、以及相关的信息),提供经销商的发行版和版本号(如:`Fedora Core 4`、`Slackware 9.1`等)。 - 描述在提问前你是怎样去研究和理解这个问题的。 - 描述在提问前为确定问题而采取的诊断步骤。 - 描述最近做过什么可能相关的硬件或软件变更。 - 尽可能地提供一个可以`重现这个问题的可控环境`的方法。 尽量去揣测一个黑客会怎样反问你,在你提问之前预先将黑客们可能提出的问题回答一遍。 以上几点中,当你报告的是你认为可能在代码中的问题时,给黑客一个可以重现你的问题的环境尤其重要。当你这么做时,你得到有效的回答的机会和速度都会大大的提升。 [Simon Tatham](http://www.chiark.greenend.org.uk/~sgtatham/) 写过一篇名为《[如何有效的报告 Bug](http://www.chiark.greenend.org.uk/~sgtatham/bugs-cn.html)》的出色文章。强力推荐你也读一读。 ### 话不在多而在精 你需要提供精确有内容的信息。这并不是要求你简单的把成堆的出错代码或者资料完全转录到你的提问中。如果你有庞大而复杂的测试样例能重现程序挂掉的情境,尽量将它剪裁得越小越好。 这样做的用处至少有三点。 第一,表现出你为简化问题付出了努力,这可以使你得到回答的机会增加; 第二,简化问题使你更有可能得到**有用**的答案; 第三,在精炼你的 bug 报告的过程中,你很可能就自己找到了解决方法或权宜之计。 ### 别动辄声称找到 Bug 当你在使用软件中遇到问题,除非你非常、**非常**的有根据,不要动辄声称找到了 Bug。提示:除非你能提供解决问题的源代码补丁,或者提供回归测试来表明前一版本中行为不正确,否则你都多半不够完全确信。这同样适用在网页和文件,如果你(声称)发现了文件的`Bug`,你应该能提供相应位置的修正或替代文件。 请记得,还有其他许多用户没遇到你发现的问题,否则你在阅读文件或搜索网页时就应该发现了(你在抱怨前[已经做了这些,是吧](#在提问之前)?)。这也意味着很有可能是你弄错了而不是软件本身有问题。 编写软件的人总是非常辛苦地使它尽可能完美。如果你声称找到了 Bug,也就是在质疑他们的能力,即使你是对的,也有可能会冒犯到其中某部分人。当你在标题中嚷嚷着有`Bug`时,这尤其严重。 提问时,即使你私下非常确信已经发现一个真正的 Bug,最好写得像是**你**做错了什么。如果真的有 Bug,你会在回复中看到这点。这样做的话,如果真有 Bug,维护者就会向你道歉,这总比你惹恼别人然后欠别人一个道歉要好一点。 ### 低声下气不能代替你的功课 有些人明白他们不该粗鲁或傲慢的提问并要求得到答复,但他们选择另一个极端 —— 低声下气:`我知道我只是个可悲的新手,一个撸瑟,但...`。这既使人困扰,也没有用,尤其是伴随着与实际问题含糊不清的描述时更令人反感。 别用原始灵长类动物的把戏来浪费你我的时间。取而代之的是,尽可能清楚地描述背景条件和你的问题情况。这比低声下气更好地定位了你的位置。 有时网页论坛会设有专为新手提问的版面,如果你真的认为遇到了初学者的问题,到那去就是了,但一样别那么低声下气。 ### 描述问题症状而非你的猜测 告诉黑客们你认为问题是怎样造成的并没什么帮助。(如果你的推断如此有效,还用向别人求助吗?),因此要确信你原原本本告诉了他们问题的症状,而不是你的解释和理论;让黑客们来推测和诊断。如果你认为陈述自己的猜测很重要,清楚地说明这只是你的猜测,并描述为什么它们不起作用。 **蠢问题** > 我在编译内核时接连遇到 SIG11 错误, 我怀疑某条飞线搭在主板的走线上了,这种情况应该怎样检查最好? **聪明问题** > 我的组装电脑是 FIC-PA2007 主机板搭载 AMD K6/233 CPU(威盛 Apollo VP2 芯片组), 256MB Corsair PC133 SDRAM 内存,在编译内核时,从开机 20 分钟以后就频频产生 SIG11 错误, 但是在头 20 分钟内从没发生过相同的问题。重新启动也没有用,但是关机一晚上就又能工作 20 分钟。 所有内存都换过了,没有效果。相关部分的标准编译记录如下… 由于以上这点似乎让许多人觉得难以配合,这里有句话可以提醒你:`所有的诊断专家都来自密苏里州。` 美国国务院的官方座右铭则是:`让我看看`(出自国会议员 Willard D. Vandiver 在 1899 年时的讲话:`我来自一个出产玉米,棉花,牛蒡和民主党人的国家,滔滔雄辩既不能说服我,也不会让我满意。我来自密苏里州,你必须让我看看。`) 针对诊断者而言,这并不是一种怀疑,而只是一种真实而有用的需求,以便让他们看到的是与你看到的原始证据尽可能一致的东西,而不是你的猜测与归纳的结论。所以,大方地展示给我们看吧! ### 按发生时间先后列出问题症状 问题发生前的一系列操作,往往就是对找出问题最有帮助的线索。因此,你的说明里应该包含你的操作步骤,以及机器和软件的反应,直到问题发生。在命令行处理的情况下,提供一段操作记录(例如运行脚本工具所生成的),并引用相关的若干行(如 20 行)记录会非常有帮助。 如果挂掉的程序有诊断选项(如 -v 的详述开关),试着选择这些能在记录中增加调试信息的选项。记住,`多`不等于`好`。试着选取适当的调试级别以便提供有用的信息而不是让读者淹没在垃圾中。 如果你的说明很长(如超过四个段落),在开头简述问题,接下来再按时间顺序详述会有所帮助。这样黑客们在读你的记录时就知道该注意哪些内容了。 ### 描述目标而不是过程 如果你想弄清楚如何做某事(而不是报告一个 Bug),在开头就描述你的目标,然后才陈述重现你所卡住的特定步骤。 经常寻求技术帮助的人在心中有个更高层次的目标,而他们在自以为能达到目标的特定道路上被卡住了,然后跑来问该怎么走,但没有意识到这条路本身就有问题。结果要费很大的劲才能搞定。 **蠢问题** > 我怎样才能从某绘图程序的颜色选择器中取得十六进制的 RGB 值? **聪明问题** > 我正试着用替换一幅图片的色码(color table)成自己选定的色码,我现在知道的唯一方法是编辑每个色码区块(table slot), 但却无法从某绘图程序的颜色选择器取得十六进制的 RGB 值。 第二种提问法比较聪明,你可能得到像是`建议采用另一个更合适的工具`的回复。 ### 别要求使用私人电邮回复 黑客们认为问题的解决过程应该公开、透明,此过程中如果更有经验的人注意到不完整或者不当之处,最初的回复才能够、也应该被纠正。同时,作为提供帮助者可以得到一些奖励,奖励就是他的能力和学识被其他同行看到。 当你要求私下回复时,这个过程和奖励都被中止。别这样做,让**回复者**来决定是否私下回答 —— 如果他真这么做了,通常是因为他认为问题编写太差或者太肤浅,以至于不可能使其他人产生兴趣。 这条规则存在一条有限的例外,如果你确信提问可能会引来大量雷同的回复时,那么这个神奇的提问句会是`向我发电邮,我将为论坛归纳这些回复`。试着将邮件列表或新闻群组从洪水般的雷同回复中解救出来是非常有礼貌的 —— 但你必须信守诺言。 ### 清楚明确地表达你的问题以及需求 漫无边际的提问是近乎无休无止的时间黑洞。最有可能给你有用答案的人通常也正是最忙的人(他们忙是因为要亲自完成大部分工作)。这样的人对无节制的时间黑洞相当厌恶,所以他们也倾向于厌恶那些漫无边际的提问。 如果你明确表述需要回答者做什么(如提供指点、发送一段代码、检查你的补丁、或是其他等等),就最有可能得到有用的答案。因为这会定出一个时间和精力的上限,便于回答者能集中精力来帮你。这么做很棒。 要理解专家们所处的世界,请把专业技能想像为充裕的资源,而回复的时间则是稀缺的资源。你要求他们奉献的时间越少,你越有可能从真正专业而且很忙的专家那里得到解答。 所以,界定一下你的问题,使专家花在辨识你的问题和回答所需要付出的时间减到最少,这技巧对你获得有用的答案相当有帮助 —— 但这技巧通常和简化问题有所区别。因此,问`我想更好地理解 X,可否指点一下哪有好一点说明?`通常比问`你能解释一下 X 吗?`更好。如果你的代码不能运作,通常请别人看看哪里有问题,比要求别人替你改正要明智得多。 ### 询问有关代码的问题时 如果没有提示别人应该从何入手,别要求他人帮你调试有问题的代码。张贴几百行的代码,然后说一声:`它不能工作`会让你完全被忽略。只贴几十行代码,然后说一句:`在第七行以后,我期待它显示 ,但实际出现的是 `比较有可能让你得到回应。 最有效描述程序问题的方法是提供最精简的 Bug 展示测试用例(bug-demonstrating test case)。什么是最精简的测试用例?那是问题的缩影;一小个程序片段能**刚好**展示出程序的异常行为,而不包含其他令人分散注意力的内容。怎么制作最精简的测试用例?如果你知道哪一行或哪一段代码会造成异常的行为,复制下来并加入足够重现这个状况的代码(例如,足以让这段代码能被编译/直译/被应用程序处理)。如果你无法将问题缩减到一个特定区块,就复制一份代码并移除不影响产生问题行为的部分。总之,测试用例越小越好(查看[话不在多而在精](#话不在多而在精)一节)。 一般而言,要得到一段相当精简的测试用例并不太容易,但永远先尝试这样做是一个好习惯。这种方式可以帮助你了解如何自行解决这个问题 —— 而且即使你的尝试不成功,黑客们也会看到你在尝试取得答案的过程中付出了努力,这可以让他们更愿意与你合作。 如果你只是想让别人帮忙审查(Review)一下代码,在信的开头就要说出来,并且一定要提到你认为哪一部分特别需要关注以及为什么。 ### 别把自己家庭作业的问题贴上来 黑客们很擅长分辨哪些问题是家庭作业式的问题;因为我们中的大多数都曾自己解决这类问题。同样,这些问题得由**你**来搞定,你会从中学到东西。你可以要求给点提示,但别要求得到完整的解决方案。 如果你怀疑自己碰到了一个家庭作业式的问题,但仍然无法解决,试试在用户群组,论坛或(最后一招)在项目的**用户**邮件列表或论坛中提问。尽管黑客们**会**看出来,但一些有经验的用户也许仍会给你一些提示。 ### 去掉无意义的提问句 避免用无意义的话结束提问,例如`有人能帮我吗?`或者`这有答案吗?`。 首先:如果你对问题的描述不是很好,这样问更是画蛇添足。 其次:由于这样问是画蛇添足,黑客们会很厌烦你 —— 而且通常会用逻辑上正确,但毫无意义的回答来表示他们的蔑视, 例如:`没错,有人能帮你`或者`不,没答案`。 一般来说,避免用 `是或否`、`对或错`、`有或没有`类型的问句,除非你想得到[是或否类型的回答](https://strcat.de/questions-with-yes-or-no-answers.html)。 ### 即使你很急也不要在标题写`紧急` 这是你的问题,不是我们的。宣称`紧急`极有可能事与愿违:大多数黑客会直接删除无礼和自私地企图即时引起关注的问题。更严重的是,`紧急`这个字(或是其他企图引起关注的标题)通常会被垃圾信过滤器过滤掉 —— 你希望能看到你问题的人可能永远也看不到。 有半个例外的情况是,如果你是在一些很高调,会使黑客们兴奋的地方,也许值得这样去做。在这种情况下,如果你有时间压力,也很有礼貌地提到这点,人们也许会有兴趣回答快一点。 当然,这风险很大,因为黑客们兴奋的点多半与你的不同。譬如从 NASA 国际空间站(International Space Station)发这样的标题没有问题,但用自我感觉良好的慈善行为或政治原因发肯定不行。事实上,张贴诸如`紧急:帮我救救这个毛茸茸的小海豹!`肯定让你被黑客忽略或惹恼他们,即使他们认为毛茸茸的小海豹很重要。 如果你觉得这点很不可思议,最好再把这份指南剩下的内容多读几遍,直到你弄懂了再发文。 ### 礼多人不怪,而且有时还很有帮助 彬彬有礼,多用`请`和`谢谢您的关注`,或`谢谢你的关照`。让大家都知道你对他们花时间免费提供帮助心存感激。 坦白说,这一点并没有比使用清晰、正确、精准且合乎语法和避免使用专用格式重要(也不能取而代之)。黑客们一般宁可读有点唐突但技术上鲜明的 Bug 报告,而不是那种有礼但含糊的报告。(如果这点让你不解,记住我们是按问题能教给我们什么来评价问题的价值的) 然而,如果你有一串的问题待解决,客气一点肯定会增加你得到有用回应的机会。 (我们注意到,自从本指南发布后,从资深黑客那里得到的唯一严重缺陷反馈,就是对预先道谢这一条。一些黑客觉得`先谢了`意味着事后就不用再感谢任何人的暗示。我们的建议是要么先说`先谢了`,**然后**事后再对回复者表示感谢,或者换种方式表达感激,譬如用`谢谢你的关注`或`谢谢你的关照`。) ### 问题解决后,加个简短的补充说明 问题解决后,向所有帮助过你的人发个说明,让他们知道问题是怎样解决的,并再一次向他们表示感谢。如果问题在新闻组或者邮件列表中引起了广泛关注,应该在那里贴一个说明比较恰当。 最理想的方式是向最初提问的话题回复此消息,并在标题中包含`已修正`,`已解决`或其它同等含义的明显标记。在人来人往的邮件列表里,一个看见讨论串`问题 X`和`问题 X - 已解决`的潜在回复者就明白不用再浪费时间了(除非他个人觉得`问题 X`有趣),因此可以利用此时间去解决其它问题。 补充说明不必很长或是很深入;简单的一句`你好,原来是网线出了问题!谢谢大家 – Bill`比什么也不说要来的好。事实上,除非结论真的很有技术含量,否则简短可爱的小结比长篇大论更好。说明问题是怎样解决的,但大可不必将解决问题的过程复述一遍。 对于有深度的问题,张贴调试记录的摘要是有帮助的。描述问题的最终状态,说明是什么解决了问题,在此**之后**才指明可以避免的盲点。避免盲点的部分应放在正确的解决方案和其它总结材料之后,而不要将此信息搞成侦探推理小说。列出那些帮助过你的名字,会让你交到更多朋友。 除了有礼貌和有内涵以外,这种类型的补充也有助于他人在邮件列表/新闻群组/论坛中搜索到真正解决你问题的方案,让他们也从中受益。 至少,这种补充有助于让每位参与协助的人因问题的解决而从中得到满足感。如果你自己不是技术专家或者黑客,那就相信我们,这种感觉对于那些你向他们求助的大师或者专家而言,是非常重要的。问题悬而未决会让人灰心;黑客们渴望看到问题被解决。好人有好报,满足他们的渴望,你会在下次提问时尝到甜头。 思考一下怎样才能避免他人将来也遇到类似的问题,自问写一份文件或加个常见问题(FAQ)会不会有帮助。如果是的话就将它们发给维护者。 在黑客中,这种良好的后继行动实际上比传统的礼节更为重要,也是你如何透过善待他人而赢得声誉的方式,这是非常有价值的资产。 ## 如何解读答案 ### RTFM 和 STFW:如何知道你已完全搞砸了 有一个古老而神圣的传统:如果你收到`RTFM(Read The Fucking Manual)`的回应,回答者认为你**应该去读他妈的手册**。当然,基本上他是对的,你应该去读一读。 RTFM 有一个年轻的亲戚。如果你收到`STFW(Search The Fucking Web)`的回应,回答者认为你**应该到他妈的网上搜索**。那人多半也是对的,去搜索一下吧。(更温和一点的说法是 **[Google 是你的朋友](http://lmgtfy.com/)**!) 在论坛,你也可能被要求去爬爬论坛的旧文。事实上,有人甚至可能热心地为你提供以前解决此问题的讨论串。但不要依赖这种关照,提问前应该先搜索一下旧文。 通常,用这两句之一回答你的人会给你一份包含你需要内容的手册或者一个网址,而且他们打这些字的时候也正在读着。这些答复意味着回答者认为: - **你需要的信息非常容易获得**; - **你自己去搜索这些信息比灌给你,能让你学到更多**。 你不应该因此不爽;**依照黑客的标准,他已经表示了对你一定程度的关注,而没有对你的要求视而不见**。你应该对他祖母般的慈祥表示感谢。 ### 如果还是搞不懂 如果你看不懂回应,别立刻要求对方解释。像你以前试着自己解决问题时那样(利用手册,FAQ,网络,身边的高手),先试着去搞懂他的回应。如果你真的需要对方解释,记得表现出你已经从中学到了点什么。 比方说,如果我回答你:`看来似乎是 zentry 卡住了;你应该先清除它。`,然后,这是一个**很糟的**后续问题回应:`zentry 是什么?` **好**的问法应该是这样:`哦~~~我看过说明了但是只有 -z 和 -p 两个参数中提到了 zentries,而且还都没有清楚的解释如何清除它。你是指这两个中的哪一个吗?还是我看漏了什么?` ### 处理无礼的回应 很多黑客圈子中看似无礼的行为并不是存心冒犯。相反,它是直截了当,一针见血式的交流风格,这种风格更注重解决问题,而不是使人感觉舒服而却模模糊糊。 如果你觉得被冒犯了,试着平静地反应。如果有人真的做了出格的事,邮件列表、新闻群组或论坛中的前辈多半会招呼他。如果这**没有**发生而你却发火了,那么你发火对象的言语可能在黑客社区中看起来是正常的,而**你**将被视为有错的一方,这将伤害到你获取信息或帮助的机会。 另一方面,你偶尔真的会碰到无礼和无聊的言行。与上述相反,对真正的冒犯者狠狠地打击,用犀利的语言将其驳得体无完肤都是可以接受的。然而,在行事之前一定要非常非常的有根据。纠正无礼的言论与开始一场毫无意义的口水战仅一线之隔,黑客们自己莽撞地越线的情况并不鲜见。如果你是新手或外人,避开这种莽撞的机会并不高。如果你想得到的是信息而不是消磨时光,这时最好不要把手放在键盘上以免冒险。 (有些人断言很多黑客都有轻度的自闭症或亚斯伯格综合症,缺少用于润滑人类社会**正常**交往所需的神经。这既可能是真也可能是假的。如果你自己不是黑客,兴许你认为我们脑袋有问题还能帮助你应付我们的古怪行为。只管这么干好了,我们不在乎。我们**喜欢**我们现在这个样子,并且通常对病患标记都有站得住脚的怀疑。) Jeff Bigler 的观察总结和这个相关也值得一读 (**[tact filters](http://www.mit.edu/~jcb/tact.html)**)。 在下一节,我们会谈到另一个问题,当**你**行为不当时所会受到的`冒犯`。 ## 如何避免扮演失败者 在黑客社区的论坛中,你以本指南所描述的或类似的方式,可能会有那么几次搞砸了。而你会在公开场合中被告知你是如何搞砸的,也许攻击的言语中还会带点夹七夹八的颜色。 这种事发生以后,你能做的最糟糕的事莫过于哀嚎你的遭遇、宣称被言语攻击、要求道歉、高声尖叫、憋闷气、威胁诉诸法律、向其雇主报怨、不去关马桶盖等等。相反地,你该这么做: 熬过去,这很正常。事实上,它是有益健康且合理的。 社区的标准不会自行维持,它们是通过参与者积极而**公开地**执行来维持的。不要哭嚎所有的批评都应该通过私下的邮件传送,它不是这样运作的。当有人评论你的一个说法有误或者提出不同看法时,坚持声称受到个人攻击也毫无益处,这些都是失败者的态度。 也有其它的黑客论坛,受过高礼节要求的误导,禁止参与者张贴任何对别人帖子挑毛病的消息,并声称`如果你不想帮助用户就闭嘴。` 结果造成有想法的参与者纷纷离开,这么做只会使它们沦为毫无意义的唠叨与无用的技术论坛。 夸张的讲法是:你要的是“友善”(以上述方式)还是有用?两个里面挑一个。 记着:当黑客说你搞砸了,并且(无论多么刺耳)告诉你别再这样做时,他正在为关心**你**和**他的社区**而行动。对他而言,不理你并将你从他的生活中滤掉更简单。如果你无法做到感谢,至少要表现得有点尊严,别大声哀嚎,也别因为自己是个有戏剧性超级敏感的灵魂和自以为有资格的新来者,就指望别人像对待脆弱的洋娃娃那样对你。 有时候,即使你没有搞砸(或者只是在他的想像中你搞砸了),有些人也会无缘无故地攻击你本人。在这种情况下,抱怨倒是**真的**会把问题搞砸。 这些来找麻烦的人要么是毫无办法但自以为是专家的不中用家伙,要么就是测试你是否真会搞砸的心理专家。其它读者要么不理睬,要么用自己的方式对付他们。这些来找麻烦的人在给他们自己找麻烦,这点你不用操心。 也别让自己卷入口水战,最好不要理睬大多数的口水战 —— 当然,这是在你检验它们只是口水战,并且未指出你有搞砸的地方,同时也没有巧妙地将问题真正的答案藏于其后(这也是有可能的)。 ## 不该问的问题 以下是几个经典蠢问题,以及黑客没回答时心中所想的: 问题:[我能在哪找到 X 程序或 X 资源?](#q1) 问题:[我怎样用 X 做 Y?](#q2) 问题:[如何设定我的 shell 提示?](#q3) 问题:[我可以用 Bass-o-matic 文件转换工具将 AcmeCorp 文件转换为 TeX 格式吗?](#q4) 问题:[我的程序/设定/SQL 语句没有用](#q5) 问题:[我的 Windows 电脑有问题,你能帮我吗?](#q6) 问题:[我的程序不会动了,我认为系统工具 X 有问题](#q7) 问题:[我在安装 Linux(或者 X )时有问题,你能帮我吗?](#q8) 问题:[我怎么才能破解 root 帐号/窃取 OP 特权/读别人的邮件呢?](#q9) --- > 问题:我能在哪找到 X 程序或 X 资源? 回答:就在我找到它的地方啊,白痴 —— 搜索引擎的那一头。天哪!难道还有人不会用 [Google](https://www.google.com) 吗? > 问题:我怎样用 X 做 Y? 回答:如果你想解决的是 Y ,提问时别给出可能并不恰当的方法。这种问题说明提问者不但对 X 完全无知,也对 Y 要解决的问题糊涂,还被特定形势禁锢了思维。最好忽略这种人,等他们把问题搞清楚了再说。 > 问题:如何设定我的 shell 提示?? 回答:如果你有足够的智慧提这个问题,你也该有足够的智慧去 [RTFM](#RTFM),然后自己去找出来。 > 问题:我可以用 Bass-o-matic 文件转换工具将 AcmeCorp 文件转换为 TeX 格式吗? 回答:试试看就知道了。如果你试过,你就知道了答案,就不用浪费我的时间了。 > 问题:我的{程序/设定/SQL 语句}没有用 回答:这不算是问题吧,我对要我问你二十个问题才找得出你真正问题的问题没兴趣 —— 我有更有意思的事要做呢。在看到这类问题的时候,我的反应通常不外如下三种 - 你还有什么要补充的吗? - 真糟糕,希望你能搞定。 - 这关我屁事? > 问题:我的 Windows 电脑有问题,你能帮我吗? 回答:能啊,扔掉微软的垃圾,换个像 Linux 或 BSD 的开源操作系统吧。 注意:如果程序有官方版 Windows 或者与 Windows 有互动(如 Samba),你**可以**问与 Windows 相关的问题,只是别对问题是由 Windows 操作系统而不是程序本身造成的回复感到惊讶, 因为 Windows 一般来说实在太烂,这种说法通常都是对的。 > 问题:我的程序不会动了,我认为系统工具 X 有问题 回答:你完全有可能是第一个注意到被成千上万用户反复使用的系统调用与函数库文件有明显缺陷的人,更有可能的是你完全没有根据。不同凡响的说法需要不同凡响的证据,当你这样声称时,你必须有清楚而详尽的缺陷说明文件作后盾。 > 问题:我在安装 Linux(或者 X )时有问题,你能帮我吗? 回答:不能,我只有亲自在你的电脑上动手才能找到毛病。还是去找你当地的 Linux 使用群组者寻求实际的指导吧(你能在[这儿](http://www.linux.org/groups/index.html)找到用户群组的清单)。 注意:如果安装问题与某 Linux 的发行版有关,在它的邮件列表、论坛或本地用户群组中提问也许是恰当的。此时,应描述问题的准确细节。在此之前,先用 `Linux` 和**所有**被怀疑的硬件作关键词仔细搜索。 > 问题:我怎么才能破解 root 帐号/窃取 OP 特权/读别人的邮件呢? 回答:想要这样做,说明了你是个卑鄙小人;想找个黑客帮你,说明你是个白痴! ## 好问题与蠢问题 最后,我将透过举一些例子,来说明怎样聪明的提问;同一个问题的两种问法被放在一起,一种是愚蠢的,另一种才是明智的。 **蠢问题**: > 我可以在哪儿找到关于 Foonly Flurbamatic 的资料? 这种问法无非想得到 [STFW](#RTFM) 这样的回答。 **聪明问题**: > 我用 Google 搜索过 "Foonly Flurbamatic 2600",但是没找到有用的结果。谁知道上哪儿去找对这种设备编程的资料? 这个问题已经 STFW 过了,看起来他真的遇到了麻烦。 **蠢问题**: > 我从 foo 项目找来的源码没法编译。它怎么这么烂? 他觉得都是别人的错,这个傲慢自大的提问者。 **聪明问题**: > foo 项目代码在 Nulix 6.2 版下无法编译通过。我读过了 FAQ,但里面没有提到跟 Nulix 有关的问题。这是我编译过程的记录,我有什么做的不对的地方吗? 提问者已经指明了环境,也读过了 FAQ,还列出了错误,并且他没有把问题的责任推到别人头上,他的问题值得被关注。 **蠢问题**: > 我的主机板有问题了,谁来帮我? 某黑客对这类问题的回答通常是:`好的,还要帮你拍拍背和换尿布吗?`,然后按下删除键。 **聪明问题**: > 我在 S2464 主机板上试过了 X 、 Y 和 Z ,但没什么作用,我又试了 A 、 B 和 C 。请注意当我尝试 C 时的奇怪现象。显然 florbish 正在 grommicking,但结果出人意料。通常在 Athlon MP 主机板上引起 grommicking 的原因是什么?有谁知道接下来我该做些什么测试才能找出问题? 这个家伙,从另一个角度来看,值得去回答他。他表现出了解决问题的能力,而不是坐等天上掉答案。 在最后一个问题中,注意`告诉我答案`和`给我启示,指出我还应该做什么诊断工作`之间微妙而又重要的区别。 事实上,后一个问题源自于 2001 年 8 月在 Linux 内核邮件列表(lkml)上的一个真实的提问。我(Eric)就是那个提出问题的人。我在 Tyan S2464 主板上观察到了这种无法解释的锁定现象,列表成员们提供了解决这一问题的重要信息。 通过我的提问方法,我给了别人可以咀嚼玩味的东西;我设法让人们很容易参与并且被吸引进来。我显示了自己具备和他们同等的能力,并邀请他们与我共同探讨。通过告诉他们我所走过的弯路,以避免他们再浪费时间,我也表明了对他们宝贵时间的尊重。 事后,当我向每个人表示感谢,并且赞赏这次良好的讨论经历的时候,一个 Linux 内核邮件列表的成员表示,他觉得我的问题得到解决并非由于我是这个列表中的**名**人,而是因为我用了正确的方式来提问。 黑客从某种角度来说是拥有丰富知识但缺乏人情味的家伙;我相信他是对的,如果我**像**个乞讨者那样提问,不论我是谁,一定会惹恼某些人或者被他们忽视。他建议我记下这件事,这直接导致了本指南的出现。 ## 如果得不到回答 如果仍得不到回答,请不要以为我们觉得无法帮助你。有时只是看到你问题的人不知道答案罢了。没有回应不代表你被忽视,虽然不可否认这种差别很难区分。 总的来说,简单地重复张贴问题是个很糟的点子。这将被视为无意义的喧闹。有点耐心,知道你问题答案的人可能生活在不同的时区,可能正在睡觉,也有可能你的问题一开始就没有组织好。 你可以通过其他渠道获得帮助,这些渠道通常更适合初学者的需要。 有许多网上的以及本地的用户群组,由热情的软件爱好者(即使他们可能从没亲自写过任何软件)组成。通常人们组建这样的团体来互相帮助并帮助新手。 另外,你可以向很多商业公司寻求帮助,不论公司大还是小。别为要付费才能获得帮助而感到沮丧!毕竟,假使你的汽车发动机汽缸密封圈爆掉了 —— 完全可能如此 —— 你还得把它送到修车铺,并且为维修付费。就算软件没花费你一分钱,你也不能强求技术支持总是免费的。 对像是 Linux 这种大众化的软件,每个开发者至少会对应到上万名用户。根本不可能由一个人来处理来自上万名用户的求助电话。要知道,即使你要为这些协助付费,和你所购买的同类软件相比,你所付出的也是微不足道的(通常封闭源代码软件的技术支持费用比开源软件的要高得多,且内容也没那么丰富)。 ## 如何更好地回答问题 **态度和善一点。** 问题带来的压力常使人显得无礼或愚蠢,其实并不是这样。 **对初犯者私下回复。** 对那些坦诚犯错之人没有必要当众羞辱,一个真正的新手也许连怎么搜索或在哪找常见问题都不知道。 **如果你不确定,一定要说出来!** 一个听起来权威的错误回复比没有还要糟,别因为听起来像个专家很好玩,就给别人乱指路。要谦虚和诚实,给提问者与同行都树个好榜样。 **如果帮不了忙,也别妨碍他。** 不要在实际步骤上开玩笑,那样也许会毁了提问者的设置 —— 有些可怜的呆瓜会把它当成真的指令。 **试探性的反问以引出更多的细节。** 如果你做得好,提问者可以学到点东西 —— 你也可以。试试将蠢问题转变成好问题,别忘了我们都曾是新手。 尽管对那些懒虫抱怨一声 RTFM 是正当的,但能给出文档的链接(即使只是建议个 Google 搜索关键词)会更好。 **如果你决定回答,就请给出好的答案。** 当别人正在用错误的工具或方法时别建议笨拙的权宜之计(workaround),应推荐更好的工具,重新界定问题。 **正面地回答问题!** 如果这个提问者已经很深入的研究而且也表明已经试过 X 、 Y 、 Z 、 A 、 B 、 C 但没得到结果,回答 `试试看 A 或是 B` 或者 `试试 X 、 Y 、 Z 、 A 、 B 、 C` 并附上一个链接一点用都没有。 **帮助你的社区从问题中学习。** 当回复一个好问题时,问问自己`如何修改相关文件或常见问题文件以免再次解答同样的问题?`,接着再向文件维护者发一份补丁。 如果你在研究一番后才作出了回答,**展现你的技巧而不是直接端出结果**。毕竟`授人以鱼不如授人以渔`。 ## 相关资源 如果你需要个人电脑、Unix 系统和网络如何运作的基础知识,参阅 [Unix 系统和网络基本原理](http://en.tldp.org/HOWTO/Unix-and-Internet-Fundamentals-HOWTO/)。 当你发布软件或补丁时,试着按[软件发布实践](http://en.tldp.org/HOWTO/Software-Release-Practice-HOWTO/index.html)操作。 ## 鸣谢 Evelyn Mitchel 贡献了一些愚蠢问题例子并启发了编写`如何更好地回答问题`这一节, Mikhail Ramendik 贡献了一些特别有价值的建议和改进。 https://www.mkshell.com/posts/2023-07-10-programmers-sideline/ https://www.mkshell.com/posts/2023-07-10-programmers-sideline.md 2023-07-10 # 程序员的副业有什么 > 此文章由AI生成,仅供参考。 作为一名程序员,除了专业技能,还可以通过副业增加收入和丰富自己的生活。那么,程序员可以从哪些副业中选择呢? # 写作 写作是一项非常适合程序员的副业。程序员通常具有逻辑思维和表达能力,这些能力也可以帮助他们在写作方面取得好成绩。可以写技术文章、博客、小说等,通过写作分享自己的经验和见解,也可以增加自己的知名度和影响力。 ![](https://source.mkshell.com/mweb/16889596223218.jpg) ## 技能要求 1. 基本写作技能:程序员需要具备基本的写作技能,如语法、拼写、标点等,以保证文章的质量。 2. 专业知识:程序员需要具备一定的专业知识,可以通过阅读相关书籍、学习在线课程或者参与技术社区等方式来提升自己的专业知识。 3. 独立思考和分析能力:程序员需要具备独立思考和分析能力,以便能够深入挖掘问题,并给出自己的见解和建议。 4. 沟通能力:程序员需要具备良好的沟通能力,以便能够把复杂的技术问题转化为易于理解的语言,并与读者进行有效的沟通。 5. 宣传和推广能力:程序员需要具备一定的宣传和推广能力,以便能够让更多的人看到自己的文章,并提高自己的知名度和影响力。 ## 实现路径 1. 确定写作主题:程序员可以从自己的专业领域出发,根据自己的经验和知识,确定写作主题。 2. 搜集资料:程序员需要通过各种渠道搜集相关资料,如书籍、网络、技术社区等,以便为自己的文章提供充足的素材。 3. 撰写文章:程序员需要根据自己的思路和素材,撰写出高质量的文章,并注意文风、语言和结构等细节。 4. 宣传推广:程序员可以通过各种渠道宣传和推广自己的文章,如社交媒体、技术社区、博客等,以便让更多人看到自己的文章。 5. 不断学习和提高:程序员需要不断学习和提高自己的写作技能和专业知识,以便更好地为读者提供价值。 # 翻译 翻译是另一项适合程序员的副业。程序员通常具有良好的英语水平和专业术语的理解能力,这些能力也可以帮助他们成为优秀的翻译人员。可以翻译技术文档、软件说明书等,通过翻译为客户提供高质量的服务。 ![](https://source.mkshell.com/mweb/16889597169770.jpg) ## 技能要求 1. 语言能力:程序员需要具备良好的语言能力,特别是英语水平要达到一定的要求,以便能够准确理解原文和准确翻译出来。 2. 专业知识:程序员需要具备一定的专业知识,特别是对于自己所翻译的领域有一定的了解和认识,以便能够更好地理解原文和准确翻译出来。 3. 翻译技巧:程序员需要具备一定的翻译技巧,如理解上下文、掌握翻译常用词汇、准确表达等,以便能够更好地翻译出来。 4. 交流能力:程序员需要具备良好的交流能力,以便能够与客户进行良好的沟通和交流,准确了解客户的需求和要求。 5. 文化素养:程序员需要具备一定的文化素养,特别是对于文化差异的理解和认识,以便能够更好地理解原文和准确翻译出来。 ## 实现路径 1. 确定翻译领域:程序员需要确定自己所擅长的翻译领域,如技术文档、软件说明书等。 2. 搜集资料:程序员需要通过各种渠道搜集相关资料,如书籍、网络、技术社区等,以便为自己的翻译提供充足的素材。 3. 翻译文章:程序员需要根据自己的语言能力、专业知识和翻译技巧,翻译出高质量的文章,并注意文风、语言和结构等细节。 4. 校对修改:程序员需要仔细校对自己的翻译文章,并进行修改和完善,以便提高翻译质量。 5. 交付客户:程序员需要按时交付客户所要求的翻译文件,并与客户进行良好的沟通和交流,以便满足客户的需求和要求。 # 设计 设计也是一项适合程序员的副业。程序员通常具有良好的审美能力和设计思维,这些能力也可以帮助他们成为优秀的设计师。可以设计网站、APP、UI界面等,通过设计为客户提供高质量的服务。 ![](https://source.mkshell.com/mweb/16889597532078.jpg) ## 技能要求 1. 设计软件的使用:程序员需要熟练掌握一些常用的设计软件,如Photoshop、Illustrator、Sketch等,以便能够进行高质量的设计。 2. 设计原则:程序员需要了解一些基本的设计原则,如色彩搭配、排版布局、字体选择等,以便能够进行合理的设计。 3. 专业知识:程序员需要具备一定的专业知识,特别是对于自己所设计的领域有一定的了解和认识,以便能够更好地进行设计。 4. 创意思维:程序员需要具备一定的创意思维,以便能够为客户提供独特的设计方案。 5. 客户沟通:程序员需要具备良好的客户沟通能力,以便能够准确了解客户需求和要求,并提供满足客户需求的设计方案。 ## 实现路径 1. 确定设计领域:程序员需要确定自己所擅长的设计领域,如网站设计、APP设计、UI界面设计等。 2. 搜集灵感:程序员需要通过各种渠道搜集相关灵感,如网站、杂志、设计社区等,以便为自己的设计提供灵感。 3. 设计方案:程序员需要根据自己的创意和灵感,制定出合理的设计方案,并考虑客户需求和要求。 4. 制作设计:程序员需要使用相应的设计软件,制作出高质量的设计,并考虑颜色、字体、排版等细节。 5. 客户反馈:程序员需要与客户进行良好的沟通和交流,听取客户反馈意见并进行修改和优化。 # 教育培训 教育培训也是一项适合程序员的副业。程序员通常具有丰富的经验和专业知识,这些能力也可以帮助他们成为优秀的教育培训人员。可以开设编程课程、技术培训等,通过教育培训为学生提供高质量的服务。 ![](https://source.mkshell.com/mweb/16889597851804.jpg) ## 技能要求 1. 专业知识:程序员需要具备一定的专业知识,特别是对于自己所教授的领域有一定的了解和认识,以便能够深入挖掘问题,并给出自己的见解和建议。 2. 教学能力:程序员需要具备良好的教学能力,以便能够把复杂的技术问题转化为易于理解的语言,并与学生进行有效的沟通。 3. 独立思考和分析能力:程序员需要具备独立思考和分析能力,以便能够深入挖掘问题,并给出自己的见解和建议。 4. 沟通能力:程序员需要具备良好的沟通能力,以便能够与学生进行有效的沟通,解答学生的问题,并调整教学方式。 5. 宣传和推广能力:程序员需要具备一定的宣传和推广能力,以便能够让更多的人知道自己的教育培训服务,并提高自己的知名度和影响力。 ## 实现路径 1. 确定教育培训领域:程序员需要确定自己所擅长的教育培训领域,如编程、数据分析、人工智能等。 2. 制定教学计划:程序员需要根据自己的经验和知识,制定出合理的教学计划,并考虑学生的需求和要求。 3. 教学实施:程序员需要使用相应的教学工具和方法,进行高质量的教学实施,并注意与学生进行良好的互动和沟通。 4. 教学评估:程序员需要对教学效果进行评估,并根据评估结果进行调整和优化。 5. 宣传推广:程序员需要通过各种渠道宣传自己的教育培训服务,如社交媒体、论坛、技术社区等,以便吸引更多的学生。 # 小结 总之,作为一名程序员,可以选择多种副业来增加收入和丰富自己的生活。需要注意的是,在选择副业时要考虑自己的兴趣和专业特长,选择适合自己的副业才能更好地发挥自己的优势。 https://www.mkshell.com/posts/2022-04-11-jianyu-week-8/ https://www.mkshell.com/posts/2022-04-11-jianyu-week-8.md 2022-04-11 # 煎鱼周刊|Python有什么Bug?内存优化: 纹理压缩技术!彻底搞懂零拷贝(Zero-Copy)技术!苹果 WWDC 2022 正式官宣!为什么很多人非说凤梨不是菠萝?从一线回到家乡二线城市定居,你后悔了吗?深入了解“外包”。 > 发现这次的内容很少啊。噢!原来上周只上班了三天呀,嘿嘿! ## 技术 ### [Python 中有什么不容易让人察觉的有趣的事实?](https://daily.zhihu.com/story/9747167) ![](https://source.mkshell.com/mweb/16496385835653.jpg) 其实就是编程语言的bug。。 ### [MySQL Varchar 类型尾部空格详解](https://www.infoq.cn/article/5hR9jHSFfZz42rUEA3ON?utm_source=rss&utm_medium=article) 1. 开发人员在处理前端传过来的字符串时没有执行 trim(),所以导致与数组中元素匹配的时候没有匹配到,也就没能剔除对应的元素,"a".equals("a ") 的结果肯定是 false 嘛。 2. MySQL 在查询时会忽略掉字符串最后的空格,所以导致 xxx+空格 作为查询条件时和 xxx 为同一效果。 ### [内存优化: 纹理压缩技术](https://mp.weixin.qq.com/s/V7sK1WwQ4D3LikK2AHgrZQ) ![](https://source.mkshell.com/mweb/16496388723449.jpg) > 而纹理压缩顾名思义是一种压缩的纹理格式,它通常会将纹理划分为固定大小的块(block)或者瓦片(tile),每个块单独进行压缩,整体显存占用更低,并且能直接被 GPU 读取和渲染(无需 CPU 解码),举例来说,一张1024x1024 的 JPEG 图片,使用RGBA格式,显存占用在 4M~5.3M 左右,而如果采用 ASTC_4x4 纹理压缩格式后,理论内存占用约在1.3M左右,相比普通纹理,可以减少70%+内存,具体数据见本文第三部分。 ### [彻底搞懂零拷贝(Zero-Copy)技术](https://juejin.cn/post/7083793623594041375) ![](https://source.mkshell.com/mweb/16496390003573.jpg) 如果应用程序不对数据做修改,从内核缓冲区到用户缓冲区,再从用户缓冲区到内核缓冲区。两次数据拷贝都需要 CPU 的参与,并且涉及用户态与内核态的多次切换,加重了 CPU 负担。 我们需要降低冗余数据拷贝、解放 CPU,这也就是零拷贝 Zero-Copy 技术。 零拷贝技术的几个实现手段包括:mmap+write、sendfile、sendfile+DMA 收集、splice 等。 ## 新鲜事 ### [苹果 WWDC 2022 正式官宣!iOS 16 即将来临](http://www.dgtle.com/news-1547750-14.html) ![](https://source.mkshell.com/mweb/16496390485786.jpg) 属于是“年年换新”了。 ### [100个克隆网站](https://github.com/GorvGoyl/Clone-Wars) ![](https://source.mkshell.com/mweb/16492961325777.jpg) 适合于开发资源有限,暂用这些大网站的UI作产品快速迭代的同学。 ## 其他 ### [为什么很多人非说凤梨不是菠萝?](https://daily.zhihu.com/story/9747242) ![](https://source.mkshell.com/mweb/16492058171286.jpg) 看了一眼,只能说,凤梨是好吃点的/高级点的菠萝。 ### [Matrix Talk丨从一线回到家乡二线城市定居,你后悔了吗?](https://sspai.com/post/72376) 一线的同学都希望能自己能回家乡,享受着家乡生活带来的安逸感。殊不知,我们大多数居住在一线的灵魂,已被一线的紧张生活给洗涤过,可能再也回不去了。大概我们向往的不是回去家乡生活,我们向往的是回到家乡放下浮躁的心的勇气。 by the way,其实我们一生当中都需要不断地进行学习的,只是我们没有意识到这个事实罢了。如果说认识到这个事实,我们还会想着“回家”吗。 ### [我想谈一谈外包,请不要“妖魔化”它。](https://juejin.cn/post/7081448250460356622) > 我们有时候会听到招聘人员说自己是二方外包,或者直接说自己不是外包,只是合同签署公司有所差别,和正式员工没有什么区别。 最直接的区别,三方外包的合同都是与独立的第三方人力资源公司签署,二方外包的合同是与目标公司的关联公司(多为控股子公司)签署。 _先这样吧_ 更多地关注[造壳](https://www.mkshell.com)。 https://www.mkshell.com/posts/2022-04-06-jianyu-week-7/ https://www.mkshell.com/posts/2022-04-06-jianyu-week-7.md 2022-04-06 # 煎鱼周刊|Kafka/TensorFlow等框架在商业应用中的实践。对象存储的过去、现在、未来。京东旗下社区团购业务京喜拼拼将裁员!Spring 出现了堪比 Log4j 的超级大漏洞?熬夜对皮肤的危害可逆吗? 啊哈哈!都周三了才想起上一周的周刊(个人阅读总结周刊hh),放假有点过头了(虽然是只能宅在家里的上海人)。今天又是元气满满(早起核酸、股票大跌)的一天呢! ## 技术 ### [MySQL 表分区?涨知识了!](https://juejin.cn/post/7079781551415754782) > 无论是哪种存储引擎,只要一张表的数据量过大,就会导致 _.myd、_.myi 以及 \*.ibd 文件过大,数据的查找就会变的很慢。 ### [基于 Kafka 的实时数仓在搜索的实践应用](https://my.oschina.net/vivotech/blog/5465792) > 随着业务的增量,存储的数据类型也随之增加了,然后我们需要使用的大数据集群,利用数据仓库来将这些数据进行分类存储 数据仓库存储数据是有时延的,通常时延为T+1 Kafka 的出现,给日益增长的复杂业务,提供了新的存储方案。 ![](https://source.mkshell.com/mweb/16484325019555.png) 结合上述对 Kafka 的综合分析,结合搜索的业务场景,引入 Kafka 作为消息队列,复用大数据平台(BDSP)的能力作为计算引擎和存储,具体架构如下图所示: ![](https://source.mkshell.com/mweb/16484325840894.png) ### [TensorFlow在美团外卖推荐场景的GPU训练优化实践](https://tech.meituan.com/2022/03/24/tensorflow-gpu-training-optimization-practice-in-meituan-waimai-recommendation-scenarios.html) ![](https://source.mkshell.com/mweb/16484340250591.jpg) ### [专访阿里云罗庆超:对象存储的过去、现在、未来](https://www.infoq.cn/article/yMVVwC2BMk13ZwUisIIS?utm_source=rss&utm_medium=article) > 阿里云见证了数据湖存储发展过程,技术的演进就是解决客户需求的过程。 - 数据湖存储 1.0 阶段。它是用 HDFS 存热数据,对象存储 OSS 存温冷数据,实现冷热分级。 - 数据湖存储 2.0 阶段。它指数据全部采用对象存储,但在客户计算环境还需部署 HDFS 元数据。 阿里云的数据湖存储已经演进到 3.0 阶段,数据湖存储 1.0 发展到 2.0 是优化掉客户计算环境的 HDFS 热数据集群来降低成本,数据湖存储 2.0 发展到 3.0 是优化掉客户计算环境的 HDFS 元数据来减少运维难度,现在的数据湖存储 3.0 通过 OSS-HDFS 提供全兼容的接口,让 Hadoop 生态应用平滑上云。在云上数据湖的过程中,深深的感受到生态兼容、性价比、易用性是非常关键的需求。 ### [你确定(a == 1 && a == 2 && a == 3)不能为true?](https://juejin.cn/post/7079936779914051615) ![](https://source.mkshell.com/mweb/16486051306425.jpg) ### [专车数据层架构进化往事:好的架构是进化来的,不是设计来的](https://my.oschina.net/makemyownlife/blog/5496417) ![](https://source.mkshell.com/mweb/16488611405543.png) ## 新鲜事 ### [京东旗下社区团购业务京喜拼拼将裁员](https://www.williamlong.info/archives/6751.html) > 据悉,京喜事业群确实会裁员,主要集中在京喜拼拼业务(京东旗下的社区团购业务),会裁撤一些区域,但并不是涉及到的区域人员都会被优化,有一些人员会转移到保留区域,人员优化比例为10%-15%,公司未来将着重于战略聚焦、区域聚焦、能力聚焦。 ### [华为首次披露云营收金额,芯片问题正寻求系统性突破](https://www.infoq.cn/article/mSGjH12TWxFaNJvpWuje?utm_source=rss&utm_medium=article) ![](https://source.mkshell.com/mweb/16485151362859.png) 华为 2021 年年度报告报告下载地址:[https://www-file.huawei.com/minisite/media/annual_report/annual_report_2021_cn.pdf](https://www-file.huawei.com/minisite/media/annual_report/annual_report_2021_cn.pdf) ### [苹果申请新音频专利:耳机不用戴,挂在衣领上听](http://www.dgtle.com/news-1547673-14.html) ![](https://source.mkshell.com/mweb/16484335711041.png) ### [豆瓣私密小组将停止服务](https://www.williamlong.info/archives/6754.html) > 2022年3月28日,下线私密小组创建入口,公开小组不再支持转私密。 2022年3月29日~2022年4月18日,豆瓣将开通私密小组转公开通道,组长可以发送邮件,申请将私密小组转为公开小组。 2022年4月18日,私密小组将全部停用(组员仅可浏览小组历史讨论),组长仍可提交私密转公开邮件申请。 2022年6月30日,私密小组功能正式停止服务(小组历史讨论将全部转为仅自己可见,组员仅可查看小组资料),且不再接收私密转公开邮件申请。 ### [Spring 出现了堪比 Log4j 的超级大漏洞?官方回应来了](https://www.infoq.cn/article/yKWU6v6UktvAtvxVvQ2W?utm_source=rss&utm_medium=article) ![](https://source.mkshell.com/mweb/16488618062612.jpg) ## 金融 ### [金龙鱼正在成为海天最大的敌人](http://xueqiu.com/8430907036/215267546) > 2021年,海天味业实现营业收入250.04亿元,同比增长9.71%;归属于上市公司股东的净利润为66.71亿元,同比增长4.18%。 自2014年上市以来,这是海天味业业绩增速最小的一年。 ![](https://source.mkshell.com/mweb/16484329540240.jpg) > 2021年1月,海天推出了食用油品牌“油司令”。同时,悄悄上线了食用油、大米等产品。 在2021年3月份的投资者调研中,海天味业提到食用油还需要一定时间去布局。 在2021年年报中,海天味业丝毫未提粮油业务最新进展与销售数据,而是与醋、鸡精、料酒等业务一起归到了“其他”中。 在3月25日举行的2021年度业绩说明会上海天味业也会在米面粮油、火锅底料等跨界产品上逐步发力。 金龙鱼在2021年年报中也没有披露调味品业务具体的销售数据,而是将酱油、醋、蚝油等调味品业务归到了厨房食品中。 从2021年的动作来看,金龙鱼的攻势更强,海天味业更多的是在防守。 ## 其他 ### [区分字体的 16 种特征](https://sspai.com/post/71957) ![](https://source.mkshell.com/mweb/16484323229122.jpg) ### [PyWebIO:以脚本方式编写交互式Web](https://github.com/pywebio/PyWebIO) ![](https://source.mkshell.com/mweb/16484342760632.jpg) ![](https://source.mkshell.com/mweb/16484342985983.jpg) 看起来还蛮像jupyter。。 ### [熬夜对皮肤的危害可逆吗?](https://daily.zhihu.com/story/9747022) ![](https://source.mkshell.com/mweb/16486887644731.jpg) - 睡眠与皮肤之间有着量化的关系。睡眠问题,足以导致皮肤各项参数的变化,并最终影响到整体的观感和气色。 - 节律紊乱的群体中,其衰老更加明显。 ### [一人独享整片天空的浪漫:私人飞机选购指南](https://sspai.com/post/72110) ???? ![](https://source.mkshell.com/mweb/16488621647843.jpg) _先这样吧_ \[wp-rss-aggregator\] 更多地关注[造壳](https://www.mkshell.com)。 https://www.mkshell.com/posts/2022-03-29-database-sql-transaction-concurrency-lock/ https://www.mkshell.com/posts/2022-03-29-database-sql-transaction-concurrency-lock.md 2022-03-29 # 想掌握数据库事务背后的并发原理,请看这15张图 > 摘要:通过一个转账问题的场景,将数据库9种锁、3种读、4种隔离级别一次性串联起。 本文分享自华为云社区[《将数据库9种锁、3种读、4种隔离级别一次性串联起来,用15张图呈现背后数据库事务背后的并发原理》](https://bbs.huaweicloud.com/blogs/335953?utm_source=oschina&utm_medium=bbs-ex&utm_campaign=ei&utm_content=content),作者: breakDawn。 前段时间开发时,正好遇到了2个进程同时更新一行记录时引发的bug,虽然问题最终解决了,但自己对背后的运行逻辑仍旧一头雾水。事后尝试简单翻了下各种博客资料,还有《高性能mysql》那本书时,发现大部分是将一堆八股文概念堆砌在一起,很少完整串联过这堆概念。 于是我重新完整学习了这些概念和底层原理, 通过一个转账问题的场景,将这些概念全部关联起来。 将下面这些数据库的概念单独拿出来时,相信很多人都有了解或者记忆过,但是将这些概念全部串联在一起时,可能就会很混乱。 我这里举个例子: - 排他锁、共享锁 - 行锁、表锁、意向锁、间隙锁、next-key锁 - 悲观锁、乐观锁 - 两阶段锁协议 - LCBB锁并发控制协议、MVCC多版本控制协议 - 脏读、不可重复读、幻读 - RU\\RC\\RR\\SE隔离级别 然后自己问自己一个问题: 1. 这一堆锁的关联关系究竟是什么? 2. 各隔离级别究竟是怎么用各种锁+MVCC来解决事务读问题的? 首先,我们完全不考虑数据库引擎、隔离级别设置之类的,就当作你用一个超简陋的儿科级别数据库来存放和更新数据。 假设你的商城服务正好在同时执行如下的2种事情 张三给穷光蛋李四转账100元。 李四尝试下单购买100元的衣服 李四在最开始余额只有0元钱。 注意因为是同时执行,在没有做任何保护的情况下,就可能会出现下图这样的情况 ![](https://source.mkshell.com/mweb/16485192446514.jpg) 可以看到李四明明没有钱,却扣费了,变成了很奇怪的-100元。 **Q:** 那这个有问题的读过程叫什么? **A:** 这个过程就叫做脏读。 即更新回退的时,另一个事务读到了脏数据,判断失误,导致做了错误的处理。 根本原因是2个事务都是先查后扣,却没有提前保护的形式 **Q:** 在不修改数据库隔离级别的情况下, 我们可以如何用sql语句手动解决这个脏读? **A:** 那很显然就是加锁对事务过程做提前保护, 不让B去判断和扣费。 sql语句里有个 ”for update“ 语法, 会手动锁住李四那一行,在调用commit后释放 具体见下面绿色的标注部分: ![](https://source.mkshell.com/mweb/16485192649882.jpg) **Q:** 刚才看到”锁住李四这一行“, 那么这个就叫行级锁。 什么情况下会变成锁住整个表? **A:** name ='李四’这句话, 如果name是索引列的话,就会加行锁 如果不是索引列, 就会变成表锁。 换言之, 行锁的本质是在索引节点上加锁 如果无法在索引节点上加锁,那就会直接变成整张表的锁,代价就会很大。 另外表锁也可以单独用lock table的语法手动加锁 **Q:** 如果一个事务A申请了行锁,锁住某一行, 另一个事务B申请了表锁,那B会被阻塞吗? **A:** B事务既然申请表锁,说明可能会用到A中的每一行。 B申请的流程可以是下面这样: 1. 判断表是否已被其他事务用表锁锁表 2. 判断表中的每一行是否已被行锁锁住。 但2这一步也太耗时了。 因此A申请行锁前,会优先申请一个意向锁,再申请行锁。 然后B申请时,第2步改成判断意向锁即可,有意向锁就阻塞。 简单点说, 意向锁就是行锁操作用来阻塞表锁用的。 但行锁和行锁之间不会互相阻塞,除非行有冲突。 刚才看到的for update会限制其他并行事务的所有读写操作,而且是2个事务上都加了”for update“。 那么这个锁就叫做”排他锁“, 属于非常强势的锁, 相当于其他读写操作马上全部拦住了。 这里使用排他锁来解决脏读的原因是因为后面有查询余额+扣余额的代码,写这段代码的人必须做提前保护,以避免自己读到一个可能被修改的数据,导致判断和修改失误。 和排他锁对应的是“共享锁”,也就是熟知的读写锁。 可以让多个事务同时读,但是不允许修改 。 手动加共享锁的方式:把for update改成 lock in share mode即可 **Q:** 那么什么时候使用共享锁比排他锁要好呢? **A:** 可以看下面的例子: ![](https://source.mkshell.com/mweb/16485193116415.jpg) 可以看到没有查自身+更新自身的操作, 仅仅是查+更新其他表,表之间也互不关联,对余额的实时性也不是要求太高。 - 如果都加排他锁,各种select操作就会很慢。 - 但如果不加共享锁, T6这边删除时,就可能产生冗余数据,所以还是得加锁。 **Q:** 那我加的共享锁(S锁)和排他锁(X)什么时候释放呢?是每次执行完update马上释放吗? **A:** 这里就涉及了“两阶段锁”协议。 - 加锁阶段:在该阶段可以进行加锁操作。在对任何数据进行读操作之前要申请并获得S锁(共享锁,其它事务可以继续加共享锁,但不能加排它锁),在进行写操作之前要申请并获得X锁(排它锁,其它事务不能再获得任何锁)。加锁不成功,则事务进入等待状态,直到加锁成功才继续执行。 - 解锁阶段:当事务释放了一个封锁以后,事务进入解锁阶段,在该阶段只能进行解锁操作不能再进行加锁操作。 说人话, 就是在事务中需要加锁时再加锁, 直到commit完一次性解锁。 为什么要两阶段锁,看到的一句话是 若并发执行的所有事务均遵守两段锁协议,则对这些事务的任何并发调度策略都是可串行化的。 **Q:** 两阶段锁协议可以避免死锁吗? **A:** 不能避免,但是可以通过死锁检测算法进行事务解除。 重新回到张三李四转账+下单的场景上来。 for update这种锁,其实也是一种“悲观锁” ,加锁解锁比较耗时, 默认经常发生竞争。 但如果我的转账和下单过程要求非常快,每次只有几毫秒,那加悲观锁成本就太大了 这时候就可以手动使用乐观锁, 需要你自己在余额表里增加version列,增加后如下所示: ![](https://source.mkshell.com/mweb/16485193445413.jpg) 这样就不需要特地加锁了,每次循环判断即可,前提是冲突发生概率比较低,阻塞时间比较短。 刚才一个小小的脏读,就已经解决了下面3个问题 - 排他锁和共享锁的区别:前者是拒绝所有读写 , 后者是允许并发读拒绝写 - 行锁和表锁的区别: 前者是对单行加锁 , 后者是对整表加锁, 区别是 是否涉及索引 - 悲观锁和乐观锁的区别: 前者主动用数据库自带的锁, 后者自己添加version版本号,外加一个两阶段锁协议 继续回到脏读问题, 前面我们学习的所有概念,都是和数据库自身隔离级别无关,使用数据库的锁语法或者version版本号来避免。 但数据库发展这么强大,怎么可能需要我们频繁自己写这种复杂逻辑,于是数据库诞生了隔离级别设置。 前面会发生脏读的隔离级别, 叫做RU(read uncommited) 即RU级别时, 我可以在别的事务没完全commit好时就读到数据。 **Q:** 先来个小问题,RU级别没有任何锁,对吗? **A:** 错误, RU级别做update等增删改操作时,仍然会默认在事务更新操作中增加排他锁,避免update冲突。 切记脏读的发生原因,是查询+更新+回滚时没加锁导致其他查询操作出现失误判断。 即查询这块可能读到没提交的数据,导致错误,而不是更新的并发问题。 **Q:** 当我们的数据库被设置成RC级别(Read commited)时, 可以解决脏读, 那么背后是怎么解决的呢? **A:** 业界有两种方式 - LBCC基于锁的并发控制(Lock-Based Concurrency Control)) - MVCC基于多版本的并发控制协议(Multi-Version Concurrency Control) LBCC其实就是类似前面手动用悲观锁的方式, 事务操作中查询时默认试图加锁,因此就可能被update的排他锁阻塞住,避免了脏读。 但代价就是效率很低。很多场景下,select的次数是远大于update的。 所以InnoDb 基于乐观锁的概念, 想了一个MVCC,自己在事务的背后实现了一套类似乐观锁的机制来处理这种情况。 确保了尽可能不在读操作上加锁, 排他锁只对更新操作生效。 **Q:** MVCC究竟是怎么做的呢? **A:** 简单来说,就是默认给每个数据行加了一个版本号列TRX_ID和回滚版本链ROLL_BT,具体可以看《高性能mysql》书里的这段描述: ![](https://source.mkshell.com/mweb/16485193914204.jpg) 简而言之 - 查的时候,只查当前事务之前的记录,或者回滚版本比当前大的已删记录。 - 增的时候,加新版本的记录 - 删的时候,把老记录标记上回滚版本 - 改的时候,本质上是加新记录, 同时把老记录标上回滚版本 **Q:** MVCC机制下, 什么是快照读,什么是当前读? **A:** - 快照读:对于select读操作,统一默认不加锁,使用历史版本数据。 - 当前读:对于insert、update、delete操作,仍然需要加X锁,因为涉及了数据变更,必须使用最新数据进行修改 **Q:** 那么回到刚才的脏读问题, MVCC究竟是怎么在读不加锁的情况下, 解决脏读的? **A:** 首先,每次select都不用任何锁, 每次都是快照读,不会阻塞,因此会变成下面这样: ![](https://source.mkshell.com/mweb/16485194181776.jpg) 总结这个图,就是 1. 每次读时,会生成一个readView,用来记录当前还没提交的事务版本号。 2. 根据自己事务的版本号version,去寻找小于自己当前版本且不在readView集合中的记录。 这样的话就保证了读的数据必须是已经完成提交的,是不是很简单? **Q:** 如果事务B中不做余额判断,支持直接赊账+扣费, 那是不是会导致先扣费,然后回滚成0这样的情况? **A:** 不会。 上面提过, MVCC中更新操作都是“当前读”,仍然需要加X锁, 且因为涉及了数据变更,必须使用最新数据版本进行修改 换言之, update等操作, 还是会加锁,且用最新版本更新,避免了脏更新的问题,如下: ![](https://source.mkshell.com/mweb/16485194358615.jpg) **Q:** 上面这个过程有什么隐患 **A:** 如果1个事务中连续读2次余额,可能有“不可重复读”的风险,即前后读的数据发生了不一致 如下所示 ![](https://source.mkshell.com/mweb/16485194447709.jpg) 因此RC隔离级别无法解决 “不可重复读的问题” **Q:** RR(可重复读,Repeat Read)的隔离级别又是怎么解决上面这个问题的? **A:** 本质上就是readView生成时的区别 上面RC不可重复读的图中可以看到,每次读时,都取了最新的readView。 这可能导致事务A提交后, 事务B观察到的readView集合发生了变化。 因此RR机制改变了readView的生成方式, 每次读时只使用事务B最开始拿到的那个readView,这样永远就只取老的数据了。 ![](https://source.mkshell.com/mweb/16485194447709.jpg) **Q:** 那读问题中的幻读又是什么? **A:** 刚才的”不可重复读“,是一个事务中查询2次结果,发现值对不上。 而”幻读“,是指一个事务中查询2批结果,发现这2批数量对不上,就好象发生了幻觉。 就像下图所示展示: ![](https://source.mkshell.com/mweb/16485194699737.jpg) **Q:** RR隔离级别中的MVCC机制可以解决上面的问题吗? **A:** 可以解决。 通过查询的快照读,能够保证只查询到同一批数据。 ![](https://source.mkshell.com/mweb/16485194777814.jpg) **Q:** 那如果像下面这样, 事务A连续做两次更新呢,单纯靠MVCC能避免更新操作的幻读么? **A:** 如果只依靠MVCC,那就无法避免了, 因为update操作是”当前读“,每次取最新版本做更新, 这会导致update中的读操作出现幻读,前后更新的记录数量不一样了。 ![](https://source.mkshell.com/mweb/16485194863901.jpg) **Q:** 那数据库怎么处理这种2次updete中间做insert的幻读情况呢? **A:** 之前有了解到, update过程仍然会加锁, RR级别会启用一个叫”间隙锁“(Gap锁)的玩意,专门来防这样情况。 即调用 update xxx where name ='李四’时, 不仅仅在李四的行上加锁, 更会在中间所有行的间隙、左右边界的两边,加上一个gap间隙锁,就像下面这个图一样: ![](https://source.mkshell.com/mweb/16485194945106.jpg) 可以看到,订单D的插入过程被update过程的间隙锁拦住了,于是无法插入,置到事务结束才会释放。 因此事务中两次update之间的幻读是可以避免的,也能。 **Q:** 那行锁、间隙锁、next-key锁是什么区别? **A:** 行锁就是单个行(单个索引节点)加锁 间隙锁就是在行(索引节点之间)加锁 next-key就是“行锁+间隙锁”,一起使用。 **Q:** 如果name这个字段不是索引,而是普通字段,那间隙锁会怎么加? **A:** 那就会给整个表的所有间隙都加上锁! 因为数据库无法确认到底是哪个范围,所以干脆全加上。 这就会导致整表锁住,性能很差。 **Q:** 那是不是只要name是索引,就不会给整个表全加间隙锁了? **A:** 不对, 如果where条件写的有问题,不符合最左匹配原则,那也会导致索引失效, 以至于给整个表加锁。 **Q:** 刚才看到说RR可以解决2次select之间的幻读, 也能解决2次update之间的幻读, 那为什么很多资料里,仍然说RR不能解决幻读? **A:** 这个问题我也是翻了好多资料, 终于找到了一个合理的解释。 看下面这个场景: ![](https://source.mkshell.com/mweb/16485195050634.jpg) 发现什么区别没, 事务B的insert操作,发生在了事务A的update之前。因此事务B的insert操作没有被间隙锁阻塞。 而update用的是当前读, 于是更新的数量和 最初select的数量匹配不上了。 Mysql官方给出的幻读解释是:只要在一个事务中,第二次select多出了row就算幻读,所以这个场景下,算出现幻读了。 这也就是下面这个图的来源: ![](https://source.mkshell.com/mweb/16485195138430.jpg) **Q:** 那串行化serializable隔离级别,为什么就能避免幻读了? **A:** Se级别时,会从MVCC并发控制退化为基于锁的并发控制(LCBB)。 不区别快照读和当前读 所有的读操作都是当前读,读加读锁(S锁),写加写锁(X锁)。在该隔离级别下,读写冲突,因此并发性能急剧下降,在MySQL/InnoDB中不建议使用。 这就是我们文章最开头手动加锁的那个过程了。 _先这样吧_ 更多地关注[造壳](https://www.mkshell.com)。 https://www.mkshell.com/posts/2022-03-28-jianyu-week-6/ https://www.mkshell.com/posts/2022-03-28-jianyu-week-6.md 2022-03-28 # 煎鱼周刊|Java 18正式发布!将 ETL 任务减少 30%的指标中台?战火中,艺术品会面临怎样的风险?稚晖君又有新玩意了!为什么说知乎只值10亿美金? > 上海的疫情可算是整得,一言难尽啊。。 ## 技术 ### [Java 18正式发布](https://www.oracle.com/java/) ![](https://source.mkshell.com/mweb/16479993404010.jpg) 我怎么老记得Java8发布还是在不久前~ - JEP 400:指定 UTF-8 作为标准 Java API 的默认字符集。通过此更改,依赖于默认字符集的 API 将在所有实现、操作系统、区域设置和配置中保持一致。 - JEP 408:引入一个简单的 Web 服务器。提供一个命令行工具,来启动一个只提供静态文件的最小网络服务器,它没有 CGI 或类似 servlet 的功能可用。该工具用于原型设计、临时编码和测试目的,尤其是在教学环境中。 - JEP 413:支持在 Java API 文档中加入代码片段。为 JavaDoc 的 Standard Doclet 引入一个 @snippet 标记,以简化 API 文档中嵌入示例源代码的难度。 - JEP 416 :用方法句柄重新实现核心反射。在 java.lang.invoke 的方法句柄之上,重构 java.lang.reflect 的方法、构造函数和字段,使用方法句柄处理反射的底层机制将减少 java.lang.reflect 和 java.lang.invoke 两者的 API 维护和开发成本。 - JEP 417:Vector API(第三孵化器)。引入一个 API 来表达向量计算,这些计算在运行时可以编译为支持的 CPU 架构上的最佳向量指令,从而实现优于等效标量计算的性能。 - JEP 418:互联网地址解析 SPI。定义用于主机名和地址解析的服务提供者接口 (SPI),以便java.net.InetAddress可以使用平台内置解析器以外的解析器。 - JEP 419:外部函数和内存 API(第二孵化器)。引入了一个新 API, Java 程序可以通过它与 Java 运行时之外的代码和数据进行互操作。通过有效地调用外部函数(即 JVM 外的代码),并安全地访问外部内存(即不由 JVM 管理的内存),外部函数和内存 API 使 Java 程序能够调用本机库并处理本机数据,而不具有 JNI 的脆弱性和危险。 - JEP 420: switch 模式匹配表达式。使用 switch 表达式和语句的模式匹配以及对模式语言的扩展来增强 Java 编程语言。将模式匹配扩展到 switch 允许针对多个模式测试表达式,每个模式都有特定的操作,可以简洁安全地表达复杂的面向数据的查询。 - JEP 421:弃用 Finalization 功能。Java 1.0 中引入的 Finalization 旨在帮助避免资源泄漏问题,然而这个功能存在延迟不可预测、行为不受约束,以及线程无法指定等缺陷,导致其安全性、性能、可靠性和可维护性方面都存在问题,因此将其弃用,用户可选择迁移到其他资源管理技术,例如try-with-resources 语句和清洁器。 ### [将 ETL 任务减少 30%,指标中台究竟是什么?](https://www.infoq.cn/article/HK8AXVDSE5QTVMa5Fy9h?utm_source=rss&utm_medium=article) ![](https://source.mkshell.com/mweb/16479988995236.png) ## 新鲜事 ### [在乌克兰的众多博物馆中有哪些重要的馆藏作品?战火中,艺术品会面临怎样的风险?](https://daily.zhihu.com/story/9746562) > · 乌克兰有 7 处联合国认定的世界文化遗产;重要博物馆集中在基辅、利沃夫、敖德萨等几个城市 · 乌克兰艺术遗产主要有两类,一是基督教影响下的艺术,包括建筑、马赛克、壁画,二是民间美术工艺品,包括绘画、纺织品、刺绣、陶瓷等。 · 战争当中艺术品面临的主要风险来自直接毁于战火、军队的没收、士兵的劫掠以及蓄意破坏。 ### [如何看待 node-ipc 包以反战为名进行供应链投毒?](https://www.zhihu.com/question/522144107) 开源无国界,开源作者有国界。 ### [ElectronBot:稚晖君又有新玩意了](https://github.com/peng-zhihui/ElectronBot) ![](https://source.mkshell.com/mweb/16478430564460.jpg) 一个人能加班干这么多事也是离谱。。 ### [因工程师被未成年黑客“社会工程”,微软、英伟达等多家企业源代码被批量偷走](https://www.infoq.cn/article/LkXtlQbFEprK7LRWL3uE?utm_source=rss&utm_medium=article) 果然人才是最大的漏洞。。 ## 金融&投资 ### [从内容到商业化,为什么说知乎只值10亿美金?](http://xueqiu.com/7847648151/214458880) > 如果看财报的数据,和周源描述的“知乎愿景”,理想和现实,还有较大的差距。 ### [有全球竞争优势的中国公司和行业ETF](http://xueqiu.com/1175294405/214629884) > 无人机、安防监控、消费电子产品、游戏产业、互联网应用(TikTok)、新能源、化工重工医疗器械、电子通信、老品牌。 ### [2022 年,科技企业融资指南](https://www.huxiu.com/article/511054.html) ![](https://source.mkshell.com/mweb/16484291574521.jpg) 方案都齐全了,就显得像“新时代科技公司融资八股文” ## 其他 ### [「思维反刍」是什么意思?](https://daily.zhihu.com/story/9746625) > 反刍是侵入性的。它会突如其来地闯入你的脑海中,而且不挑场合、不挑时间,你感觉控制不了它。 反刍时,我们的注意力只集中在痛苦的表现、引发痛苦的原因和痛苦带来的后果上 反刍和反思的区别: - 反刍是重复消极的无效自我鞭笞;而反思能带来新的洞见 - 反刍伴随着自我厌恶,反思伴随着自我关怀 ### [RetroBar – 回到经典 Windows 95、98、Me、2000 或 XP 风格的 Windows 任务栏](https://www.appinn.com/retrobar-for-windows/) ![](https://source.mkshell.com/mweb/16478256308869.jpg) ### [再见 Xshell!这个开源的终端工具更酷炫!](https://github.com/electerm/electerm) ![](https://source.mkshell.com/mweb/16478434922804.jpg) - 支持多平台(Linux、Mac、Windows); - 支持多国语言; - 支持添加快速命令; - 支持批量输入命令在一个终端或所有终端上执行; - 作为终端 SSH / SFTP 的客户端(类似于 Xshell); - 切换窗口可见性的全局热键; - 双击直接编辑(小)远程文件; - 使用内置编辑器编辑(小)本地文件; - 使用公钥 + 密码进行身份验证; - 将书签 / 主题 / 快速命令同步到 Github / Gitee secret gist 等; ### [那些长期喝茶的人,后来怎么样了?](https://daily.zhihu.com/story/9746673) > - 茶里的活性物质包含茶多酚、茶氨酸、咖啡因等; > - 喝茶可提升抗氧化能力,但对长期健康的影响缺乏有力证据; > - 喝茶有助控制体重,效果主要和咖啡因有关; > - 长期喝茶对免疫力提升的效果现在还不能下定论。 _先这样吧_ 更多地关注[造壳](https://www.mkshell.com)。 https://www.mkshell.com/posts/2022-03-20-jianyu-week-5/ https://www.mkshell.com/posts/2022-03-20-jianyu-week-5.md 2022-03-20 # 煎鱼周刊|图解数据库事务背后的并发原理!2022全面数字化发展下的消费市场洞察!互联网和中概股动荡!为什么美联储一提加息,市场就慌了? > 这一周,在上海,是WFH的一周。 T.T ## 技术 ### [图解数据库事务背后的并发原理](https://my.oschina.net/u/4526289/blog/5479471) > 排他锁、共享锁 行锁、表锁、意向锁、间隙锁、next-key锁 悲观锁、乐观锁 两阶段锁协议 LCBB锁并发控制协议、MVCC多版本控制协议 脏读、不可重复读、幻读 RU\\RC\\RR\\SE隔离级别 然后自己问自己一个问题: 这一堆锁的关联关系究竟是什么? 各隔离级别究竟是怎么用各种锁+MVCC来解决事务读问题的? ## 数据报告&分析 ### [QuestMobile2022全面数字化发展下的消费市场洞察](https://www.questmobile.com.cn/research/report-new/225) ![](https://source.mkshell.com/mweb/16477669778336.jpg) ## 新鲜事 ### [美国自动驾驶监管里程碑:重磅发布新法规,允许全自动驾驶车辆取消方向盘](https://www.infoq.cn/article/Mw4INke3UNpdkymqjTDa?utm_source=rss&utm_medium=article) ![](https://source.mkshell.com/mweb/16477671124433.jpg) 没有好的基建大概是搞不出来大规模的自动化驾驶吧。。 ### [消息称腾讯阿里将大规模裁员](https://www.williamlong.info/archives/6738.html) 互联网似乎是真进入了寒冬?还是只是谣言? ### [在美上市的中资民营股集体崩盘](https://www.williamlong.info/archives/6739.html) ![](https://source.mkshell.com/mweb/16473927415687.jpg) 不过怎么说呢,本周也是我国和美国算是达成协议救市了一波。。 ## 其他 ### [运动鞋的演变史](https://daily.zhihu.com/story/9746295) ![](https://source.mkshell.com/mweb/16472198781202.jpg) 蛮好玩的,为什么我们运动鞋的鞋垫这么花里胡哨呢~ ### [2022 中国 CRM 行业研究报告](https://pdf.dfcfw.com/pdf/H3_AP202202151547183618_1.pdf?1644945215000.pdf) ![](https://source.mkshell.com/mweb/16477673498672.jpg) ### [中国高铁站,为什么离市区这么远](https://www.huxiu.com/article/507336.html) 总结下,主要是控制成本和起名字没有起好。但里面的数据还是可以了解下的。 ### [为什么美联储一提加息,市场就慌了?](https://daily.zhihu.com/story/9746537) > 要把问题讲透,至少要讲五个大方面: > > 1. 利率如何影响资产定价 > 2. 过去两年美国的财政政策对低收入群体资产负债表带来的影响 > 3. 美联储的预期管理(加不加息,多快加息,一次加多少息,总共加多少息) > 4. 加息带来的利率传导 > 5. 通胀 _先这样吧_ 更多地关注[造壳](https://www.mkshell.com)。 https://www.mkshell.com/posts/2022-03-12-jianyu-week-4/ https://www.mkshell.com/posts/2022-03-12-jianyu-week-4.md 2022-03-12 # 煎鱼周刊|三月樱花开了!各类工程师路线图!Ruby on Rails怎么就衰落了!中概与敢抄底吗? > 上海的疫情越发的严重了,不敢也不想出去追寻樱花🌸了,只能看看网上好看的照片~ ### [你知道吗,三月樱花开了](https://sspai.com/post/71386) ![](https://source.mkshell.com/mweb/16470809394508.jpg) ## 技术 ### [各类工程师路线图](https://roadmap.sh/roadmaps) ![](https://source.mkshell.com/mweb/16466248264092.jpg) 可以说,每一条线路从头学起,其实都不会简单。行业里面每一位同事有的广阔的知识面,都是通过上学/上班日积月累的。懂得的知识面越广,学的也越快。 如后端的路线图: ![](https://source.mkshell.com/mweb/16470822864602.jpg) 一想到非科班出身,要从头开始学习,还是蛮可怕的。因此,非科班出身的,建议实用为主,从实践开始学起。 ### [分布式数据库排序及优化](https://my.oschina.net/vivotech/blog/5476004) #### 原方案 为了提高分布式排序的性能,每个数据分片本身也要参与排序。这样在proxy上得到分片返回的数据是有序的,proxy对有序的数据重排序可以采用归并排序或者优先级队列排序方法,大大减轻proxy的压力。 可以根据proxy内存大小配置sort buffer大小,通常默认为10M。如果一次查询语句关联N个数据分片,则需要到sort buffer按照N份进行切分,每个数据分片对应切分后的sort buffer大小为10M/N。 ![](https://source.mkshell.com/mweb/16470826547279.jpg) **方案缺陷:** - 这种方法只能满足小数据量排序,当排序的数据量较大我们可以选择调大proxy上的sort buffer。但是调大sort buffer会占用更多的内存资源,所以不能无限制的调大sort buffer。 #### 优化方案1 **思路:** 把数据分片返回的有序数据保存到磁盘上,然后对磁盘数据进行重排序。下面将介绍一种优化方案,针对大数据量进行分布式排序的方法。 ![](https://source.mkshell.com/mweb/16470831623829.jpg) **方案缺陷:** - 磁盘也是有限的,当数据量太大在proxy上磁盘也可能无法容纳需要排序的数据。 - proxy上把数据存在磁盘,存在大量的磁盘IO。 - 浪费了很多网络带宽和磁盘IO。 #### 优化方案2 **思路:** proxy上磁盘上不保存数据分片数据,一次从数据分片拉取固定大小的有序数据,proxy把拉取的数据填充到分片对应的sort buffer,sort buffer中数据使用完后再次从对应的数据分片上拉取。 ![](https://source.mkshell.com/mweb/16470837992965.jpg) 方案限制: - 数据分片节点本身支持排序,绝大多数数据分片都是支持排序的。 - 数据分片需要支持分批读取。 ### [Ruby on Rails 的衰落](https://www.infoq.cn/article/JNXbFeKsmVPFjQGD135P?utm_source=rss&utm_medium=article) ![](https://source.mkshell.com/mweb/16469611791693.png) 一下子告诉我们一个道理:网红语言最好不要乱学。 ### [Spark on Kubernetes 与 Spark on Yarn 不完全对比分析](https://www.infoq.cn/article/7cmvdiaNCtkck4BiRHvi?utm_source=rss&utm_medium=article) ### [10分钟了解TypeScript](https://juejin.cn/post/7073392219356397604) > 简单来说,TypeScript是JavaScript的超集,是JavaScript语言的扩展,本质上是向JavaScript语言添加了可选的静态类型和基于类的面向对象编程。 对于前端同学来说,就是常识。对于不接触前端的后端同学来说,是需要了解的知识。 ### [故事,从 Docker 讲起 | 深度揭秘阿里云 Serverless Kubernetes](https://www.infoq.cn/article/T7AiFh2MvuHutEaumeRx?utm_source=rss&utm_medium=article) ![](https://source.mkshell.com/mweb/16470811411538.jpg) ## 新鲜事 ### [B 站发布 2021 全年财报:月活用户达 2.72 亿,全年营收 194 亿](http://www.dgtle.com/news-1547424-14.html) > 哔哩哔哩发布了截至 12 月 31 日的 2021 年第四季度及全年财报。财报数据显示,哔哩哔哩第四季度营收 57.8 亿元,同比增长 50.5%,超市场预期的 57.4 亿元。 ### [中概与恒生科技,还敢抄底吗](http://xueqiu.com/4579887327/213192592) ![](https://source.mkshell.com/mweb/16470844910732.jpg) 好惨哟。 ### [Google 史上第二大收购案产生,54 亿美元收购 Mandiant](https://www.oschina.net/news/185613/google-acquire-mandiant) ### [开源 AI 代码生成器 PolyCoder:擅长 C 语言,优于 Codex](https://www.oschina.net/news/185793/open-source-ai-code-generator-polycoder) 每次这种新闻都会让程序员深思。。。 ## 思考 ### [构建产品“自增长”基因的 6 个思考](https://www.infoq.cn/article/m8MFu4ot1tApJsbbMxaD?utm_source=rss&utm_medium=article) > 如何借由产品的功能或数据帮用户生成一个可自嘲或炫耀的成果? 是否可以帮助用户自我展现自己的投入 or 付出的成果? 是否可以在用户确认获得较好体验、收获产品价值的时刻,给予用户一个把产品分享给他人的动机? 是否可以围绕着产品的使用帮助用户树立或迫近自己的“理想形象”? 是否可以借由某些奇特的用户参与形式给予用户福利? 是否可以在产品使用流程中植入某些意想不到的彩蛋? ## 金融&创投 ### [什么是 GP、LP、PE、VC、FOF?](https://www.zhihu.com/question/20062244/answer/19073986) ## 其他 ### [睡眠小技巧](https://sspai.com/post/71679) 觉,要好好地睡,别喜提字节同款猝死了。 ### [怎么炒好一盘青菜?](https://daily.zhihu.com/story/9746099) 广东人的享受。 ![](https://source.mkshell.com/mweb/16470846136478.jpg) ### [互联网公司的战略岗位是做什么的?](https://daily.zhihu.com/story/9746136) ### [喝一口好春茶](https://daily.zhihu.com/story/9746218) ![](https://source.mkshell.com/mweb/16470846528077.jpg) _先这样吧_ 更多地关注[造壳](https://www.mkshell.com)。 https://www.mkshell.com/posts/2022-03-07-jianyu-week-3/ https://www.mkshell.com/posts/2022-03-07-jianyu-week-3.md 2022-03-07 # 煎鱼周刊|Linux 内核从 C89 迁移到 C11!阿里电商压力大吗?开源作者去世后,代码谁来继承? _这周跑去看了看巨富长,看了看武康路,真不错啊哈哈_ ![R0001774](https://source.mkshell.com/mweb/R0001774.JPG) ## 技术 ### [Linux 内核从 C89 迁移到 C11 新进展](https://www.oschina.net/news/184534/linux-kernel-c89-to-c11) ![](https://source.mkshell.com/mweb/16466143376986.jpg) > Linus 宣布将在下一个内核版本 v5.18 中尝试使用 C11 标准。 咱们也算是见证过不少历史的人了,就像是Java无缘无故就是17了,Ubuntu随着年份也无缘无故到了22了,how time flies! ### [快手数据治理的那些事](https://www.infoq.cn/article/ivoTc5plfJS2YqMa8M9z?utm_source=rss&utm_medium=article) > 徐波:从我的角度出发,我觉得数据治理的未来,我觉得可能还是强调元数据,我觉得当你有了清晰的治理目标,或者说你只有先了解清楚你的目标以及链路流程当前的状态,你才有了一个治理的基础。另外一点是当有了一个基于这个目标的方法,然后不断地监控治理目标是否在往好的方向演进。 ### [比编码更重要的,是项目管理能力](https://www.infoq.cn/article/vTVQwb0aKYFIdxAtfHfu?utm_source=rss&utm_medium=article) 个人觉得,编程就是技术和艺术的融合,技术体现在个人需要对于计算机技能的不断学习,而艺术体现在当想法有多种方法时选用“正确”的“好看”的方法去实现。 ## 新鲜事 ### [那些扎根于乌克兰的知名软件企业](https://www.infoq.cn/article/zzcWUHLkEcH01PiAPlZ9?utm_source=rss&utm_medium=article) 事情过多,不想评论,仅作了解。 ### [开源作者去世后,代码谁来继承?](https://my.oschina.net/u/3859945/blog/5462442) 互联网行业也算是新鲜事物,各种开源和云更是。我们这一代人也逐渐地开始“不新鲜”了,开始考虑这类问题了。。 ### [主流电商平台复盘:阿里电商压力大吗?](http://xueqiu.com/2893497429/211940638) 个人觉得:压力大,但仍然看多它。 ![](https://source.mkshell.com/mweb/16460127431014.jpg) > 总体而言,阿里拥有非常齐全的布局,包括各类电商模式和品类(都不是轻易短时间可以完善的领域),并且还有云计算的第二增长曲线,以及相关的创新布局,仍然有着相对较为不错的基本盘。 虽然在短视频和下沉领域并非阿里最初的强项,但可以看到一些努力和进展(例如下沉市场)。 参考[阿里巴巴第三财季净利润同比下滑25%](https://www.williamlong.info/archives/6721.html) 参考[对阿里巴巴最新一季财报的看法](https://mp.weixin.qq.com/s/-UaMrG6gi0-eel1BLQ9D1Q) > 对这份财报,市场反应颇为有趣:美股开盘之后暴跌8%,可是很快顽强地拉回来了,到收盘时几乎已经没有下跌;港股则一度上涨。其中固然受到了乌克兰局势等国际宏观因素的影响,但也折射了投资人心态的微妙变化——在仔细研究财报之后,很多人发现情况其实不算太坏,还能找到一些积极因素;我们还可以进一步得出结论,即在当前环境下,如果我们对中国经济的长期潜力有信心,那么阿里巴巴仍然是分享这种长期潜力的最佳标的之一。 ### [vCards:优化 iOS/Android 来电、信息界面体验](https://github.com/metowolf/vCards) ![](https://source.mkshell.com/mweb/16466149218506.jpg) 好东西,在用了, 其实就是一个通讯录list,然后导入到手机里面,就是不太全面,可以先养着。 ### [Github 支持在 markdown 里画流程图了](https://github.blog/2022-02-14-include-diagrams-markdown-files-mermaid/) ![](https://source.mkshell.com/mweb/16466149450206.jpg) ### [webvm:在浏览器中运行虚拟机](https://github.com/leaningtech/webvm) 有空可以玩玩。 ## 其他 ### [预防猝死指南](https://sspai.com/post/71678) ![](https://source.mkshell.com/mweb/16460124532502.jpg) ![](https://source.mkshell.com/mweb/16460124781922.jpg) 干程序员要是常熬夜的话,确实会蛮担心会猝死的。。 ### [听说不吃早饭容易胖,你习惯吃早饭吗?](https://daily.zhihu.com/story/9745830) > 早餐吃不吃的问题可以说是人类长久未解之谜,主要原因就是科学证据都不一致。 **吃早餐更健康是主流证据** 在传统且稳妥的观点和证据中,表明吃早餐是健康的原因主要有: **吃早餐的人更加少会有超重和肥胖问题,也有着更加低的心血管疾病风险** ### [中餐鸡蛋流变史](http://www.zhihu.com/question/288273712/answer/2368304645?utm_campaign=rss&utm_medium=rss&utm_source=rss&utm_content=title) ![](https://source.mkshell.com/mweb/16466150699722.jpg) ### [男子穿搭入门](https://daily.zhihu.com/story/9745883) ![](https://source.mkshell.com/mweb/16466151119854.jpg) 程序员也要精致点,加班没空精致的话,那就周末精致。什么!?周末也加班? ### [工信部:未经用户同意不得自动或强制下载App](https://www.williamlong.info/archives/6728.html) 作为用户,真希望能快点整治下。。作为互联网员工,只希望别炒我鱿鱼。。 _先这样吧_ 更多地关注[造壳](https://www.mkshell.com)。 https://www.mkshell.com/posts/2022-02-28-jianyu-week-2/ https://www.mkshell.com/posts/2022-02-28-jianyu-week-2.md 2022-02-28 # 煎鱼周刊|Python弃用官方Bug管理网站迁移到Github!DeepMind 用 AI 控制核聚变!程序员在家也要好好做饭哦! ## 技术 ### [弃用官方网站!Python 将所有 Bug 迁移到 GitHub 中](https://www.oschina.net/news/184023/python-bugs-move-to-github) > Roundup / bpo 有啥问题?为啥放弃它? - 维护者从未超过 5 个 - 没有任何 CI 构建,审查和测试压力太大 - UI 老旧 - 天天给用户发垃圾邮件,还容易暴露用户邮件地址 为什么不继续优化 Roundup / bpo? - 优化成本太高,“创建和维护 GitHub 集成和审查机器人,工作量远低于继续优化并维护 Roundup 。” 从官方给出的这些缘由中,我们看得到,如果外部有较好的符合需求的平台,没有特殊的原因,我们是不会选择自建的,也可能是因为术业有专攻吧。 > 也意味着 Python 开发的基础设施已经完成了从基于 Python 的开源工具(Mercurial、Roundup)到专有的 GitHub “SAAS” 产品的全面转变。 可能这就是趋势吧。 ### [美团搜索中查询改写技术的探索与实践](https://tech.meituan.com/2022/02/17/exploration-and-practice-of-query-rewriting-in-meituan-search.html) 本文介绍了美团场景下查询改写任务上的探索和实践经验,在垂直领域搜索召回这一课题上结合实际业务场景和用户需求探索了语义判别模型、语义检索模型、图模型等前沿算法技术,积累了生活服务领域短语关联认知数据。 ![](https://source.mkshell.com/mweb/16460086714101.jpg) ### [如何从 0 到 1 搭建性能检测系统](https://www.zoo.team/article/performance-testing-system) ![](https://source.mkshell.com/mweb/16460087709286.jpg) ### [使用 Kafka 和动态数据网格进行流式数据交换](https://www.infoq.cn/article/Dl70Cks9CBakHx4Wvcae?utm_source=rss&utm_medium=article) 这里面提到了“Datamesh”,数据网格(Data Mesh)是近来受到广泛重视的一种新型架构范式。 这种架构的范式能够大规模解锁分析数据,为整个组织的机器学习、分析或数据密集型应用等大量消费场景快速解锁了对数量不断增加的分布式域数据集的访问。数据网格解决了传统的集中式数据湖或数据平台架构的常见故障模式。 ![](https://source.mkshell.com/mweb/16460089861843.jpg) ## 顶尖探索 ### [DeepMind 用 AI 控制核聚变,人造太阳实现新突破](https://www.infoq.cn/article/LqgwKaXwfbiRZO1xeHDs?utm_source=rss&utm_medium=article) 对于“训练核聚变”这种高危事情,我们通过AI介入必然是好事,一方面我们能减少失控的可能性,另一方面,AI的快速学习相比较人为调参/现实模拟有较明显的效率优势。唯一不好的点就在于,发展过快可能会导致一些反面的事情罢了,比较是核控制,总有可能被大国利用。 ## 新鲜事 ### [HowToCook:程序员在家做饭方法指南](https://github.com/Anduin2017/HowToCook) 程序员也是需要好好在家做饭的哦~ ![](https://source.mkshell.com/mweb/16459491151118.jpg) 这位同学就很好滴展示了“量化”才能更好滴控制项目: ![](https://source.mkshell.com/mweb/16459491214874.jpg) 这一个bug就提的很有灵性。 ### [你是明白了哪几个基本原理之后而厨艺大增的?](https://www.zhihu.com/question/21696230) 做饭的到了一定程度,就需要学一些更高层的知识。 ### [复盘,我的小户型的装修备忘录](https://sspai.com/post/71343) ![](https://source.mkshell.com/mweb/16460097316925.jpg) 最近家里可能装修,多学学总是好的。。 ## 其他 ### [antd-mini](https://github.com/ant-design/ant-design-mini) antd-mini 是支付宝小程序 UI 组件库,遵循 ant design 规范。 _先这样吧_ 更多地关注[造壳](https://www.mkshell.com)。.com)。.com)。 https://www.mkshell.com/posts/2022-02-20-jianyu-week-1/ https://www.mkshell.com/posts/2022-02-20-jianyu-week-1.md 2022-02-20 # 煎鱼周刊|原来Emoji的编码也很复杂!写接口到底要不要Rest?你做过不在乎结果的项目吗? 心血来潮,突然修复了博客,用于干嘛呢?写博客可能是没什么时间了。我是打算,把每周看到的科技/互联网/经济等等领域的好文章收集起来,列一个list发出来一个所谓的“周刊”,一方面是水水博客(划掉,哈哈),另一方面是个人认为,读了好文章就要分享并说说自己的感悟,有输入且做到输出(不论多少),才是有意义的阅读吧。 ## 技术 ### [Emoji 到底是什么](https://sspai.com/post/71398) 本以为Emoji虽然常用常见,就是个简简单单的编码的事情。看了后没想到还有ZWJ的操作,使得整套Emoji系统变得十分复杂,最终通过各种组合实现了表情编码的合并和拆分、人种不同肤色的表情展示的实现等等。 > 如果又因为新的 bug 导致了 iOS 崩溃也不要嘲笑,因为这真的很难 :) 哈哈,确实。 ### [“一把梭:REST API 全用 POST ”](https://coolshell.cn/articles/22173.html) 由V2ex上的一个帖子引发的争论,总结下大佬文章的观点: 1. 开发者将业务逻辑,赋予给HTTP协议中不同的动词,来表明该接口的特性,从而让调用者很轻易知道接口的幂等、缓存、流控、路由、权限等要求; 2. 符合restful规范的查询接口,get的参数能实现更复杂的查询操作; 3. 符合规范是为了更快捷更方便地开发和协作; 4. POST没有比GET更安全 ### Ask HN: What is your “I don't care if this succeeds” project? 你做过不在乎结果的项目吗? [上篇](https://news.ycombinator.com/item?id=25992782) [下篇](https://news.ycombinator.com/item?id=30283264) 论坛中发起了《你做过不在乎结果的项目吗?》的讨论,很多人分享了自己的项目,让我十分感慨 —— 我们很多人一开始走上编程的路,其实是因为一点点兴趣,我们希望能把心中的想法给实现出来,而不是像现在公司项目中还要关注什么成果什么落地。我们当时的一腔热血,似乎现在都变成了冰凉的kpi了。sad。 ### [花 10 年写一本编程语言实现的书](https://catcoding.me/2022/01/12/a-book-on-programming-language.html) 高质量技术书,免费阅读哦~冲! [点这里下载](http://craftinginterpreters.com/) ### [Kubernetes: The Documentary中文字幕完整版](https://www.bilibili.com/video/BV13q4y1h7QR) 揭晓Kubernetes及CNCF的前世今身。视频分为两部分,分别是Kubernetes的诞生立项,及随之而来的容器战争。 还没来得及有空去看,先mark住了(希望还会记得看。。 youtube原版:[https://www.youtube.com/watch?v=BE77h7dmoQU](https://www.youtube.com/watch?v=BE77h7dmoQU) ### [Chrome 和 Firefox 的 100 版本号会带来什么问题?](https://www.oschina.net/news/182959/version-100-in-chrome-and-firefox) 这件事情表明了,大厂的员工也是很多细节都不会考虑的,居然一个版本号也能整那么多事情。。 ### [平台化建设思路浅谈](https://www.cnblogs.com/lesofn/p/15906154.html) > 随着业务的不断发展,软件系统不可避免的走向熵增:复杂度越来越高、研发效率越来越差、稳定性逐渐降低等。这时抽象核心能力,走向平台化的道路成为很多系统的首要选择。 平台化已经成了我们建设产品的常规手段,但执行归执行,写代码很简单,却会经常缺乏系统性的思考,多总结或者多看看别人的思考,或许能提升自我。 ## 经济 ### [1993 年左右发生的人民币大贬值是怎么回事?](https://www.zhihu.com/question/19991948/answer/13588547?utm_campaign=rss&utm_medium=rss&utm_source=rss&utm_content=title) 技术之外也看看,扩展下视野也是极好的。 _先这样吧_ 更多地关注[造壳](https://www.mkshell.com)。 https://www.mkshell.com/posts/2020-05-18-internet-adv-formula-noun/ https://www.mkshell.com/posts/2020-05-18-internet-adv-formula-noun.md 2020-05-18 # 互联网广告计费公式及名词整理 ## 计费公式 CPA计费收入 \=ad-action*cpa=消费行动次数*消费行动每次收费 \=ad-click*cvr-a_cpa=广告点击次数*转化率\_消费行动每次收费 \=ad-show*ctr2_cvr-a_cpa=广告展示次数*每次展示点击数*转化率*消费行动每次收费 \=refresh*asr_asn_ctr2_cvr-a_cpa=刷新次数*每次刷新出现广告的概率*每次刷新展现广告条数*每次展示点击数*转化率*消费行动每次收费 CPC计费收入 \=ad-click*acp=广告点击次数*平均点击价格 \=ad-show*ctr2_acp=广告展示次数*每次展示点击数\_平均点击价格 \=refresh*asr_asn_ctr2_acp=刷新次数*每次刷新出现广告概率*每次刷新广告条数*每次展示点击数\_平均点击价格 CPM计费收入 \=ad-show*cpm2=每千次广告展示*每千次展示收费 \=refresh*asr_asn_cpm2=刷新次数*每次刷新出现广告概率*每次刷新广告条数*每千次展示收费 ## 名词解释 CPM1:cost per thousand1 按每千次检索收费,百度用户每使用百度一千次做搜索为百度带来的收益。也就是用户使用了百度服务一千次所带来的百度的收入。这个是衡量百度赚钱能力的重要指标。是百度变现能力的基本衡量标准。是根据每个季度的总收入除以PV(Page View)算出来的。 CPM2:cost per thousand2 按每千次展示收费,广告所在的网页被浏览了一千次,即认为该广告展示了一千次。 这个指标是衡量广告客户的广告展现了一千次的时候,客户交付的费用(注意客户是在用户点击广告后才付费的)这个指标可以衡量用户对于广告的感兴趣程度。从广告客户的角度讲,也可以衡量在百度投放广告的费用多少,以及投放的有效性。 CPM3:cost per thousand3 表示平均每千次有广告展现的检索请求对应的广告收入,衡量单次有广告展现检索的平均收入贡献。这个指标是衡量当搜索结果的页面有广告展现的时候,每展现一千次,百度能够拿到的收入。有的搜索结果是没有广告展示的(CPM1把这些PV也计算进去了)。 CPC:cost per click 按每次点击收费,是目前最流行的搜索引擎营销的付费方式,即只有当用户点击观看了你的推广链接后,才发生费用。 CPA:cost per action 按每次用户消费行动收费,被认为将是互联网广告未来必然的发展方向,但由于不同产品的广告对action的定义和回报率有太大差异,实现过于复杂,目前在全球范围尚无搜索引擎使用 CPA收费的成功先例。 CTR:点击率,在这里特指广告或推广链接出现后被用户点击的机率。CTR还 将可以进一步的细分。 CTR1:表示平均每次检索请求对应的广告点击数,衡量单次广告检索的平均点击贡献。理论上 CTR1 可能大于 1。因为每次检索客户可以点击一个广告,看了后,在回去原来的检索页,点击下一个广告。 CTR2:表示平均每次广告展现对应的广告点击数,衡量单次广告展现的平均点击贡献。 CTR3:表示平均每次有广告展现的检索请求对应的广告点击数,衡量单次有广告展现检索的平均点击贡献。理论上 CTR3 可能大于 1。 PV:检索量,百度搜索框的搜索次数 ADPV:出广告的检索量,即PV中,有广告展现的搜索数量 CLK:click number,广告的总点击次数 CSM: 百度广告主的总广告费支出 ASN:平均展现条数。即:如果展现了搜索推广广告,每次平均展现多少条 ACP:Average Click Price 平均点击价格总消费/总点击 PVR:Page View Rates, PV比率,出广告的检索量 / 检索量 [原文链接](https://www.jianshu.com/p/41dbef9b6e5e) https://www.mkshell.com/posts/2020-05-17-how-to-build-web-server-python/ https://www.mkshell.com/posts/2020-05-17-how-to-build-web-server-python.md 2020-05-17 # 由浅入深 | 如何一步步地搭建一个Web服务器 > 鱼相信要成为一个更好的开发人员,你必须更好地理解你每天使用的底层软件系统,包括编程语言、编译器和解释器、数据库和操作系统、web服务器和web框架。 ## 引言 有一天,一个女人出去散步到一个建筑工地,看见三个男人在工作。 她问第一个男人:“你在干什么?”第一个男人被这个问题惹恼了,叫道:“你没看见我在砌砖吗?” 她对回答不满意,问第二个人在做什么。第二个男人回答说:“我在建一堵砖墙。”然后,他把注意力转向第一个男人,说:“嘿,你刚砌过了墙的尽头。你需要把最后一块砖头摘下来。” 她又一次对答案不满意,问第三个人在干什么。那人抬头望着天空对她说:“我正在建造世界上最大的大教堂。”当他站在那里仰望天空时,另外两个人开始为那块乱七八糟的砖头争吵起来。男人转身对前两个人说:“嘿,伙计们,别担心那块砖头。这是一个内墙,它会被粉刷过,没有人会看到那块砖。换一层吧。” 这个故事的寓意是,当你了解整个系统,了解不同部分(砖块、墙壁、大教堂)如何组合在一起时,你可以更快地识别和解决问题(错误的砖块)。 文章这样的开头,与《创建一个简单的Web服务器》有什么关系? 鱼相信要成为一个更好的开发人员,你必须更好地理解你每天使用的底层软件系统,包括编程语言、编译器和解释器、数据库和操作系统、web服务器和web框架。而且,为了更好更深入地了解这些系统,你必须从头开始,一块一块地,一堵墙一堵墙地重新构建它们。 孔子这样说: > 听而易忘 ![](https://source.mkshell.com/15894636139892.jpg) > 见而易记 ![](https://source.mkshell.com/15894770244189.jpg) > 做而易懂 ![](https://source.mkshell.com/15894636430623.jpg) 鱼希望你可以相信,对不同的软件系统进行造轮子,来学习它们的工作方式,是一个好办法。 在这个由三部分组成的系列中,鱼将向你展示如何构建自己的基本Web服务器。Here we go! ## 初识Web服务器 首先,什么是Web服务器? ![](https://source.mkshell.com/15894637158363.jpg) 简单滴说,它是一个网络服务器,位于物理服务器之上(没看错,就是服务器上的服务器),然后它等待客户端发送请求。当它接收到请求时,它生成一个响应并将其发送回客户端。客户端和服务器之间的通信使用HTTP协议进行。客户端可以是你的浏览器,也可以是任何其他讲HTTP的软件。 ### 最简单的Web服务器 一个非常简单的Web服务器实现是什么样子的? 可以看下鱼的这个例子,这个例子是用Python编写的(在Python3.7+上进行了测试),但是即使你不了解Python(这是一种很容易掌握的语言,请尝试!)你仍然应该能够从下面的代码和解释中理解概念: ```python # Python3.7+ import socket HOST, PORT = '', 8888 listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) listen_socket.bind((HOST, PORT)) listen_socket.listen(1) print(f'Serving HTTP on port {PORT} ...') while True: client_connection, client_address = listen_socket.accept() request_data = client_connection.recv(1024) print(request_data.decode('utf-8')) http_response = b"""\ HTTP/1.1 200 OK Hello, World! """ client_connection.sendall(http_response) client_connection.close() ``` 将以上代码保存为`webserver1.py`,然后使用以下命令运行它: ```python $ python webserver1.py Serving HTTP on port 8888 … ``` 现在,在Web浏览器的地址栏输入`http://localhost:8888/hello`,按Enter,然后你应该看到“Hello World!”显示在浏览器中,如下所示: ![](https://source.mkshell.com/15894637703067.jpg) 快去实践一遍吧,简单地一匹! ### 分析Web服务器工作原理 鱼们来分析一下它到底是如何工作的。 首先让鱼们从你输入的网址开始。它被称为URL,其基本结构如下 ![](https://source.mkshell.com/15894637858744.jpg) 这个就是你要告诉浏览器的地址,相当于你要求浏览器查找和连接到的Web服务器的地址,以及服务器上要为你获取的页面(路径)。 但是,在浏览器发送HTTP请求之前,它首先需要与Web服务器建立TCP连接。然后浏览器通过TCP连接向服务器发送HTTP请求,并等待服务器发送HTTP响应。 当你的浏览器收到来自服务器的应答时,它会显示出来,在这种情况下,它会显示“Hello, World!” 现在,让鱼们来更详细地探讨,客户端和服务器在发送HTTP请求和响应之前如何建立TCP连接。为此,鱼使用所谓的套接字进行模拟。你将通过在命令行上使用telnet手动模拟浏览器,而不是直接使用浏览器。 在运行Web服务器的同一台计算机上,在命令行上启动telnet会话,指定要连接到本地主机和8888的端口,然后按Enter键: ```python $ telnet localhost 8888 Trying 127.0.0.1 … Connected to localhost. ``` 此时,你已经与本地主机上运行的服务器建立了TCP连接,并准备发送和接收HTTP消息。在下面的图片中,你可以看到一个服务器必须经过的标准过程,才能接受新的TCP连接。 ![](https://source.mkshell.com/15894638365874.jpg) 更多地关注[造壳](https://www.mkshell.com)。 同时,鱼们继续试验,在同一个telnet会话中,输入“GET /hello HTTP/1.1”,然后按Enter: ```python $ telnet localhost 8888 Trying 127.0.0.1 … Connected to localhost. GET /hello HTTP/1.1 HTTP/1.1 200 OK Hello, World! ``` 就在此时,你手动模拟了你的浏览器!你发送了一个HTTP请求并得到了一个HTTP响应。这是HTTP请求的基本结构: ![](https://source.mkshell.com/15894638657234.jpg) HTTP请求由这些元素组成: 1. HTTP方法(GET) 2. 表示所需服务器上的“页面”的路径(/hello) 3. 协议版本(HTTP/1.1) 为了简单起见,鱼们的Web服务器此时完全忽略了上面的请求数据。你也可以输入任何垃圾而不是“GET/hello HTTP/1.1”,然后你仍然会得到一个“hello,World!”回应。 输入请求行并按Enter键后,客户机将请求发送到服务器,服务器读取请求行,打印请求行并返回正确的HTTP响应。 以下是服务器发送回客户端的HTTP响应(在本例中为telnet): ![](https://source.mkshell.com/15894639167435.jpg) 让鱼们解剖一下。HTTP响应由这几个元素组成: 1. 响应状态行(协议版本+状态码/返回码),HTTP/1.1 200 OK, 2. 一个必需的空行, 3. HTTP响应体。 响应状态行`HTTP/1.1 200 OK`由HTTP协议版本、HTTP状态码和HTTP状态码原因短语OK组成。当浏览器得到响应时,它会显示响应的主体,这就是为什么你会看到“Hello,World!”在你的浏览器中。 这就是Web服务器工作的基本模式。总而言之: 1. Web服务器创建一个监听套接字,并开始在循环中接受新连接。 2. 客户端启动一个TCP连接,并在成功建立它之后,客户端向服务器发送一个HTTP请求,服务器返回HTTP响应结果,其中包含了展示给用户看的响应内容。 要建立TCP连接,客户端和服务器都使用套接字。 现在你有了一个非常基本的工作Web服务器,你可以使用浏览器或其他HTTP客户端进行测试。正如你已经看到并希望尝试过的那样,通过使用telnet并手动键入HTTP请求,你也可以成为一个人工HTTP客服端。 接下来鱼们要提出一个问题: “在不对服务器进行任何更改的前提下,你如何在新开发的Web服务器下,运行/适配不同的Django应用程序、Flask应用程序和pyrampid应用程序” ## 解耦Web服务器和Python应用程序 —— WSGI 在过去,你对Python Web框架的选择会限制你对可用Web服务器的选择,反之亦然。如果框架和服务器设计为协同工作,那么一般是可行的: ![](https://source.mkshell.com/15896124414258.jpg) 但是,当你尝试将服务器和非设计为协同工作的框架结合在一起时,可能会遇到不match的问题: ![](https://source.mkshell.com/15896124625130.jpg) 基本上,你必须使用协同工作的东西,但有可能不是你想要使用的东西。比如你希望用ServerA的某个特性和FrameworkB的某个功能,但是FrameworkA不能满足你。 那么,如何确保可以使用多个Web框架运行Web服务器,而不必对Web服务器或Web框架进行代码更改呢?这个问题的答案就是Python Web服务器网关接口(简称WSGI,发音为wizgy)。 ![](https://source.mkshell.com/15896124795168.jpg) WSGI允许开发人员将Web框架与Web服务器解耦。现在,你可以混合和匹配Web服务器和Web框架,并选择适合你需要的配对。例如,可以使用Gunicorn、Nginx/uWSGI或Waitress运行Django、Flask或Pyramid。这样的解耦,得益于服务器和框架中的WSGI支持: ![](https://source.mkshell.com/15896125022603.jpg) 因此,WSGI就是问题的答案。你的Web服务器必须实现WSGI接口的服务器部分,并且要求所有的python web框架都已经实现了WSGI接口的框架端。这样,就可以将它们混合使用,而无需修改服务器代码以适应特定的Web框架。 现在你知道,Web服务器和Web框架对WSGI的支持允许你选择适合你的配对,但这也有利于服务器和框架开发人员,因为他们可以专注于自己喜欢的专业领域,而不是相互干涉。其他语言也有类似的接口:例如,Java有Servlet API,Ruby有Rack。 ### 编写自己的WSGI服务器 理想很美好,但凡事都得“Show me the code”。鱼们来看看这个非常简单的WSGI服务器实现: ```python # Tested with Python 3.7+ (Mac OS X) import io import socket import sys class WSGIServer(object): address_family = socket.AF_INET socket_type = socket.SOCK_STREAM request_queue_size = 1 def __init__(self, server_address): # Create a listening socket self.listen_socket = listen_socket = socket.socket( self.address_family, self.socket_type ) # Allow to reuse the same address listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # Bind listen_socket.bind(server_address) # Activate listen_socket.listen(self.request_queue_size) # Get server host name and port host, port = self.listen_socket.getsockname()[:2] self.server_name = socket.getfqdn(host) self.server_port = port # Return headers set by Web framework/Web application self.headers_set = [] def set_app(self, application): self.application = application def serve_forever(self): listen_socket = self.listen_socket while True: # New client connection self.client_connection, client_address = listen_socket.accept() # Handle one request and close the client connection. Then # loop over to wait for another client connection self.handle_one_request() def handle_one_request(self): request_data = self.client_connection.recv(1024) self.request_data = request_data = request_data.decode('utf-8') # Print formatted request data a la 'curl -v' print(''.join( f'< {line}\n' for line in request_data.splitlines() )) self.parse_request(request_data) # Construct environment dictionary using request data env = self.get_environ() # It's time to call our application callable and get # back a result that will become HTTP response body result = self.application(env, self.start_response) # Construct a response and send it back to the client self.finish_response(result) def parse_request(self, text): request_line = text.splitlines()[0] request_line = request_line.rstrip('\r\n') # Break down the request line into components (self.request_method, # GET self.path, # /hello self.request_version # HTTP/1.1 ) = request_line.split() def get_environ(self): env = {} # The following code snippet does not follow PEP8 conventions # but it's formatted the way it is for demonstration purposes # to emphasize the required variables and their values # # Required WSGI variables env['wsgi.version'] = (1, 0) env['wsgi.url_scheme'] = 'http' env['wsgi.input'] = io.StringIO(self.request_data) env['wsgi.errors'] = sys.stderr env['wsgi.multithread'] = False env['wsgi.multiprocess'] = False env['wsgi.run_once'] = False # Required CGI variables env['REQUEST_METHOD'] = self.request_method # GET env['PATH_INFO'] = self.path # /hello env['SERVER_NAME'] = self.server_name # localhost env['SERVER_PORT'] = str(self.server_port) # 8888 return env def start_response(self, status, response_headers, exc_info=None): # Add necessary server headers server_headers = [ ('Date', 'Mon, 15 Jul 2019 5:54:48 GMT'), ('Server', 'WSGIServer 0.2'), ] self.headers_set = [status, response_headers + server_headers] # To adhere to WSGI specification the start_response must return # a 'write' callable. We simplicity's sake we'll ignore that detail # for now. # return self.finish_response def finish_response(self, result): try: status, response_headers = self.headers_set response = f'HTTP/1.1 {status}\r\n' for header in response_headers: response += '{0}: {1}\r\n'.format(*header) response += '\r\n' for data in result: response += data.decode('utf-8') # Print formatted response data a la 'curl -v' print(''.join( f'> {line}\n' for line in response.splitlines() )) response_bytes = response.encode() self.client_connection.sendall(response_bytes) finally: self.client_connection.close() SERVER_ADDRESS = (HOST, PORT) = '', 8888 def make_server(server_address, application): server = WSGIServer(server_address) server.set_app(application) return server if __name__ == '__main__': if len(sys.argv) < 2: sys.exit('Provide a WSGI application object as module:callable') app_path = sys.argv[1] module, application = app_path.split(':') module = __import__(module) application = getattr(module, application) httpd = make_server(SERVER_ADDRESS, application) print(f'WSGIServer: Serving HTTP on port {PORT} ...\n') httpd.serve_forever() ``` 代码还是比较简单的(不到150行),大家都可以理解,应该不会出现陷入细节泥潭的情况。上面的服务器还可以做更多的事情——它可以运行用你喜爱的Web框架编写的基本Web应用程序,无论是Pyramid、Flask、Django还是其他Python WSGI框架。 让鱼们来运行试试看。将上述代码保存为`webserver2.py`。如果你试图在没有任何参数的情况下运行它,它会提醒错误并退出。 ``` $ python webserver2.py Provide a WSGI application object as module:callable ``` 它真的需要你的Web应用服务。要运行服务器,只需要安装Python。但是要运行使用Pyramid、Flask和Django编写的应用程序,你需要先安装这些框架,让鱼们把这三个都安装好。鱼首选的方法是使用venv创建一个虚拟环境,以免影响现有环境(venv在Python3.3版本以及以上默认自带)。只需按照下面的步骤创建并激活一个虚拟环境,然后安装所有三个Web框架。 ``` $ python3 -m venv lsbaws $ ls lsbaws bin include lib pyvenv.cfg $ source lsbaws/bin/activate (lsbaws) $ pip install -U pip (lsbaws) $ pip install pyramid (lsbaws) $ pip install flask (lsbaws) $ pip install django ``` ### 搭建WSGI + Pyramid应用程序 此时,你需要创建一个Web应用程序。鱼们先从Pyramid开始。将以下代码另存为金字塔`app.py`保存到同一目录`webserver2.py`: ``` from pyramid.config import Configurator from pyramid.response import Response def hello_world(request): return Response( 'Hello world from Pyramid!\n', content_type='text/plain', ) config = Configurator() config.add_route('hello', '/hello') config.add_view(hello_world, route_name='hello') app = config.make_wsgi_app() ``` 现在,你可以使用自己的Web服务器,来启动你的Pyramid应用程序了: ``` (lsbaws) $ python webserver2.py pyramidapp:app WSGIServer: Serving HTTP on port 8888 ... ``` 你刚才告诉服务器从python模块“Pyramid app”加载可调用的“app”,你的服务器现在可以接收请求并将它们转发到你的pyramid中叫app的应用程序。 同时,从`webserver2.py`代码中可以看出,应用程序现在只处理一个路由:/hello路由。在浏览器网站上输入`http://localhost:8888/hello`地址,按回车键,然后观察结果: ![](https://source.mkshell.com/15896126383379.jpg) 当然,你也可以在命令行中使用curl工具,也能达到同样的效果: ``` $ curl -v http://localhost:8888/hello ... ``` 你可以看下服务器和curl都输出了些什么。 ### 搭建WSGI + Flask应用程序 现在鱼们移步到Flask,跟着鱼一起做: ``` from flask import Flask from flask import Response flask_app = Flask('flaskapp') @flask_app.route('/hello') def hello_world(): return Response( 'Hello world from Flask!\n', mimetype='text/plain' ) app = flask_app.wsgi_app ``` 将上述代码保存为`flaskapp.py`,然后允许它: ``` (lsbaws) $ python webserver2.py flaskapp:app WSGIServer: Serving HTTP on port 8888 ... ``` 在浏览器网站上输入`http://localhost:8888/hello`地址,按回车键: ![](https://source.mkshell.com/15896126958845.jpg) 一样的,你也可以在命令行中使用curl工具: ``` $ curl -v http://localhost:8888/hello ... ``` ### 搭建WSGI + Django应用程序 服务器还可以处理Django应用程序吗?当然可以,试试看!不过,它涉及的内容稍微多一些,鱼建议复制整个repo并使用`djangoapp.py`。 下面的源代码基本上将Django helloworld项目(使用Django的`Django-admin.py startproject`命令预先创建)添加到当前Python路径,然后导入项目的WSGI应用程序。 ``` import sys sys.path.insert(0, './helloworld') from helloworld import wsgi app = wsgi.application ``` 将这段代码保存为`djangoapp.py`,然后运行起来: ``` (lsbaws) $ python webserver2.py djangoapp:app WSGIServer: Serving HTTP on port 8888 ... ``` 在浏览器网站上输入地址,按回车键: ![](https://source.mkshell.com/15896127704874.jpg) 虽然已经运行过很多次,但是你依然可以在命令行中使用curl工具,为了验证Django: ``` $ curl -v http://localhost:8888/hello ... ``` ok,到目前为止,鱼们把三个服务器都轮了一遍,如果你还没亲手试过,最好动下手,看是没啥用的。 ### WSGI程序分析 好吧,你已经体验过WSGI的强大功能:它允许你混合匹配你的Web服务器和Web框架。 WSGI在Python Web服务器和pythonweb框架之间提供了一个最小的接口。WSGI很简单,而且很容易在服务器端和框架端实现。 鱼们来分析下鱼们之前实现的WSGI的代码。 以下代码段显示了服务器和接口的框架端: ``` def run_application(application): """Server code.""" # This is where an application/framework stores # an HTTP status and HTTP response headers for the server # to transmit to the client headers_set = [] # Environment dictionary with WSGI/CGI variables environ = {} def start_response(status, response_headers, exc_info=None): headers_set[:] = [status, response_headers] # Server invokes the ‘application' callable and gets back the # response body result = application(environ, start_response) # Server builds an HTTP response and transmits it to the client ... def app(environ, start_response): """A barebones WSGI app.""" start_response('200 OK', [('Content-Type', 'text/plain')]) return [b'Hello world!'] run_application(app) ``` 它的工作原理如下: 1. 框架提供了一个可调用的“应用程序”(WSGI规范没有规定应该如何实现),指的就是鱼们的业务代码 2. 服务器为从HTTP客户端接收的每个请求调用可调用的“应用程序”。它将包含WSGI/CGI变量的字典“environ”和可作为参数调用的“start_response”传递给可调用的“application”。 3. 框架/应用程序生成一个HTTP状态和HTTP响应头,并将它们传递给服务器可调用的“start_response”来存储它们。框架/应用程序还返回一个响应体。 4. 服务器将状态、响应头和响应体组合成一个HTTP响应并将其传输到客户端(此步骤不是规范的一部分,但它是流中的下一个逻辑步骤,一般都是需要的) 下面是界面的可视化表示: ![](https://source.mkshell.com/15896128061100.jpg) 到目前为止,鱼们已经轮过了Pyramid、Flask和Django Web应用程序,还看到了实现WSGI规范的服务器端的服务器代码。 当你使用这些框架之一编写Web应用程序时,你可以在更高的级别上工作,而不直接使用WSGI。 但鱼知道你对WSGI接口的框架方面也很好奇,因为你正在阅读本文。 更多地关注[造壳](https://www.mkshell.com)。 因此,让鱼们创建一个极简的WSGI Web应用程序/Web框架,而不使用Pyramid、Flask或Django,并在服务器上运行它: ``` def app(environ, start_response): """A barebones WSGI application. This is a starting point for your own Web framework :) """ status = '200 OK' response_headers = [('Content-Type', 'text/plain')] start_response(status, response_headers) return [b'Hello world from a simple WSGI application!\n'] ``` 将上述代码保存为`wsgiapp.py`,运行它: ``` (lsbaws) $ python webserver2.py wsgiapp:app WSGIServer: Serving HTTP on port 8888 ... ``` 在浏览器网站上输入地址,按回车键: ![](https://source.mkshell.com/15896128445760.jpg) 这样,鱼们就实现了一个极简的WSGI Web“框架”,没错,就是这么简单。 现在,让鱼们回到服务器向客户端传输的内容。 下面是服务器在使用HTTP客户端调用金字塔应用程序时生成的HTTP响应: ![](https://source.mkshell.com/15896128747774.jpg) 这个响应有一些是你在前文中能看到的熟悉部分,但它也有一些新的内容。例如,它有四个你以前从未见过的HTTP头:内容类型、内容长度、日期和服务器。其实这些是Web服务器响应通常应该具有的头。不过,这些都不是严格要求的。报头的目的是传输关于HTTP请求/响应的附加信息。 现在你已经了解了更多关于WSGI接口的信息,同样的,下面HTTP响应,其中包含了有关生成它的部分的更多信息: ![](https://source.mkshell.com/15896128904463.jpg) 鱼还没有提到“environ”字典,但基本上它是一个Python字典,必须包含WSGI规范指定的某些WSGI和CGI变量。服务器在分析请求后从HTTP请求中获取字典的值。这就是字典的内容: ![](https://source.mkshell.com/15896129076547.jpg) Web框架使用该字典中的信息,根据指定的路由、请求方法等信息,决定使用哪个视图、从何处读取请求体以及在何处写入错误(如果有的话)。 现在,你已经创建了自己的WSGI Web服务器,并使用不同的Web框架编写了Web应用程序。而且,你还创建了一个简单的Web应用程序/Web框架。 让鱼们回顾一下你的WSGI Web服务器必须做些什么来服务针对WSGI应用程序的请求: 首先,服务器启动并加载Web框架/应用程序提供的可调用的“应用程序”; 然后,服务器读取一个请求; 然后,服务器解析它; 然后,它使用请求数据构建一个“environ”字典; 然后,它用“environ”字典调用“application”,用“start_response”作为参数调用“start_response”,并返回一个响应体; 然后,服务器使用对“application”对象的调用返回的数据以及可调用的“start_response”设置的状态和响应头来构造HTTP响应; 最后,服务器将HTTP响应发送回客户端。 ![](https://source.mkshell.com/15896129353407.jpg) 就是这些步骤,贯穿了鱼们的整个服务流程。 现在你有了一个可以工作的WSGI服务器,它可以为使用符合WSGI的Web框架(如Django、Flask、Pyramid或你自己的WSGI框架)编写的基本Web应用程序提供服务。最理想的,是服务器可以与多个Web框架一起使用,而不需要对服务器代码库进行任何更改。 但这还不够完美,甚至还有明显的缺点。 鱼们来思考一下:“为了提高你的程序的性能,你如何让你的服务器一次处理多个请求?” ## 创建并发服务器 在前面,鱼们创建了一个极简的WSGI服务器,它可以处理基本的HTTP GET请求。但是,它是一个“迭代服务器”,一次处理一个客户机请求。在处理完当前客户端请求之前,它无法接受新连接。有些客户端可能不满意,因为他们将不得不排队等候,而对于繁忙的服务器,排队现象尤其严重。 ![](https://source.mkshell.com/15896130031448.jpg) ### 串行的“迭代服务器” 鱼们来看一眼鱼们的“迭代服务器”,webserver3a.py: ``` ##################################################################### # Iterative server - webserver3a.py # # # # Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X # ##################################################################### import socket SERVER_ADDRESS = (HOST, PORT) = '', 8888 REQUEST_QUEUE_SIZE = 5 def handle_request(client_connection): request = client_connection.recv(1024) print(request.decode()) http_response = b"""\ HTTP/1.1 200 OK Hello, World! """ client_connection.sendall(http_response) def serve_forever(): listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) listen_socket.bind(SERVER_ADDRESS) listen_socket.listen(REQUEST_QUEUE_SIZE) print('Serving HTTP on port {port} ...'.format(port=PORT)) while True: client_connection, client_address = listen_socket.accept() handle_request(client_connection) client_connection.close() if __name__ == '__main__': serve_forever() ``` 为了更直观地观察服务器一次只处理一个请求,鱼们稍微“降低一下性能”,修改服务器并在向客户端发送响应后添加60秒延迟。 ![](https://source.mkshell.com/15896130401578.jpg) “降低性能”的代码保存为`webserver3b.py`: ``` ######################################################################### # Iterative server - webserver3b.py # # # # Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X # # # # - Server sleeps for 60 seconds after sending a response to a client # ######################################################################### import socket import time SERVER_ADDRESS = (HOST, PORT) = '', 8888 REQUEST_QUEUE_SIZE = 5 def handle_request(client_connection): request = client_connection.recv(1024) print(request.decode()) http_response = b"""\ HTTP/1.1 200 OK Hello, World! """ client_connection.sendall(http_response) time.sleep(60) # sleep and block the process for 60 seconds def serve_forever(): listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) listen_socket.bind(SERVER_ADDRESS) listen_socket.listen(REQUEST_QUEUE_SIZE) print('Serving HTTP on port {port} ...'.format(port=PORT)) while True: client_connection, client_address = listen_socket.accept() handle_request(client_connection) client_connection.close() if __name__ == '__main__': serve_forever() ``` 然后运行起来: ``` $ python webserver3b.py ``` 在命令行中请求一下,你马上就能看到“Hello, World!”: ``` $ curl http://localhost:8888/hello Hello, World! ``` 然后,紧接着,鱼们赶紧发起第二个请求: ``` $ curl http://localhost:8888/hello ``` 如果你足够地“赶紧”,在60秒内完成了发起了这两个请求,那么第二个请求应该不会马上产生任何输出,而应该只是hung在那里。服务器也不应该在其标准输出上打印新的请求体。下面是在鱼的Mac上的情况(右下角以黄色突出显示的窗口显示第二个curl命令挂起,等待服务器接受连接): ![](https://source.mkshell.com/15896131110306.jpg) 当你等了60秒到了,你就会看到第二个“Hello, World!”出现了,然后服务器继续hung住60秒。 ![](https://source.mkshell.com/15896131350367.jpg) 鱼们看到服务器完成对第一个curl客户机请求的服务,然后仅在第二个请求休眠60秒后才开始处理它。这一切都是因为“迭代服务器”是按顺序或迭代地进行的,一步一个步骤,或者在鱼们的情况下,一次处理一个请求。 ### Socket、进程、文件描述符是什么 为了更好地分析鱼们怎么解决这个性能问题,鱼们来谈谈客户端和服务器之间的通信。 鱼们为了让两个程序通过网络相互通信,必须使用套接字/Socket。前面的代码鱼们使用了Socker,那么什么是Socket? ![](https://source.mkshell.com/15896131976026.jpg) Socket是通信端点的抽象,它允许你的程序使用文件描述符与另一个程序通信。在本文中,鱼将特别讨论Linux/Mac OS X上的TCP/IP socket。 其中,鱼们需要理解,什么是Socket Pair(套接字对)? TCP连接的Socket Pair是一个4元组,用于标识TCP连接的两个端点:本地IP地址、本地端口、外部IP地址和外部端口。Socket Pair唯一标识网络上的每个TCP连接。标识每个连接点的两个值(IP地址和端口号)通常称为Socket。 ![](https://source.mkshell.com/15896132147027.jpg) 所以,元组{10.10.10.2:49152, 12.12.12.3:8888}是一个Socket Pair,它唯一地标识出客户端上两个终端的TCP连接;而元组 {12.12.12.3:8888, 10.10.10.2:49152}也是一个Socket Pair,标识出服务器上两个终端的TCP连接。地址12.12.12.3和端口8888两个值,能标识TCP连接的服务器端点,在这里鱼们称之为Socket(客户端依然)。 服务器创建Socket并开始接受客户端连接的标准顺序如下: ![](https://source.mkshell.com/15896132307248.jpg) 1. 服务器创建TCP/IP Socket。这是通过Python语句完成的: ``` listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) ``` 2. 服务器可能会设置一些Socket参数选项(这是可选的参数。鱼们可以看到上面实现过的服务器代码,使用的是REUSEADDR,正是为了能够反复使用同一地址)。 ``` listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) ``` 3. 然后,服务器绑定地址。bind函数为Socket分配一个本地协议地址。对于TCP,调用bind允许你指定端口号、IP地址,或者两者都指定,或者都不指定。 ``` listen_socket.bind(SERVER_ADDRESS) ``` 4. 然后,监听。 ``` listen_socket.listen(REQUEST_QUEUE_SIZE) ``` listen方法仅由服务器调用,客户端没有。它告诉内核应该接受这个Socket的传入连接请求。 完成后,服务器开始在一个循环中一次接受一个客户端连接。当有可用的连接时,accept调用返回已连接的客户端Socket。然后,服务器从连接的客户端Socket中读取请求数据,在其标准输出上打印数据,并将消息发送回客户端。然后,服务器关闭客户端连接,并准备再次接受新的客户端连接。 鱼们再来看看客户端通过TCP/IP与服务器通信所需执行的操作: ![](https://source.mkshell.com/15896133192765.jpg) 鱼们再看看客户端连接服务器再打印返回的代码,比较简单: ``` import socket # create a socket and connect to a server sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect(('localhost', 8888)) # send and receive some data sock.sendall(b'test') data = sock.recv(1024) print(data.decode()) ``` 客户端创建完Socket之后,需要连接服务端,这是通过connect函数做到的: ``` sock.connect(('localhost', 8888)) ``` 客户端只需提供远程IP地址或主机名以及要连接到的服务器的远程端口号即可。 你可能已经注意到客户端没有调用bind和accept,是的,客户端不需要调用bind,因为客户端不关心本地IP地址和本地端口号。 当客户端调用connect时,内核中的TCP/IP堆栈会自动分配本地IP地址和本地端口。本地端口称为临时端口,一般来说很快就释放了。 ![](https://source.mkshell.com/15896133586290.jpg) 一般,常用服务的端口称为常用端口,如HTTP服务的80端口,SSH服务的22端口。 如果你想知道你的客服端的本地端口是什么,可一启动Python shell并与在本地主机上运行的服务器建立客户端连接,然后查看内核为你创建的套接字分配的临时端口(在尝试以下示例之前启动服务器webserver3a.py或webserver3b.py): ``` >>> import socket >>> sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) >>> sock.connect(('localhost', 8888)) >>> host, port = sock.getsockname()[:2] >>> host, port ('127.0.0.1', 60589) ``` 在上述情况下,内核将临时端口60589分配给Socket。 除了Socket之外,鱼需要快速介绍一些其他重要的概念,分别是进程和文件描述符。你很快就会明白这些概念为什么很重要。 什么是进程?进程只是执行程序的一个实例。例如,当服务器代码被执行时,它被加载到内存中,执行程序的一个实例称为进程。内核记录了一堆关于进程的信息,它的进程ID就是一个例子,用来跟踪它。当你运行迭代服务器webserver3a.py或webserver3b.py时,你只是运行一个进程。 ![](https://source.mkshell.com/15896133872931.jpg) 在终端中启动webserver3b.py: ``` $ python webserver3b.py ``` 在另一个终端中,使用ps命令查看这个进程: ``` $ ps | grep webserver3b | grep -v grep 7182 ttys003 0:00.04 python webserver3b.py ``` ps命令显示你实际上只运行了一个Python进程`webserver3b`。当创建一个进程时,内核会为它分配一个进程ID,即PID。在UNIX中,每个用户进程都有一个父进程,该父进程又有自己的进程ID,称为父进程ID,简称PPID。鱼假设你在默认情况下运行BASH shell,当你启动服务器时,会创建一个带有PID的新进程,其父PID设置为bashshell的PID。 ![](https://source.mkshell.com/15896134209839.jpg) 再次启动Python shell,它将创建一个新进程,然后鱼们使用os.getpid() 和os.getppid() 系统调用获取pythonshell进程的PID和父PID(bashshell的PID)。 然后,在另一个终端窗口中为PPID(父进程ID,在鱼的例子中是3148)运行ps命令和grep。在下面的屏幕截图中,你可以看到鱼的Mac OS X上的子Python shell进程和父BASH shell进程之间的父子关系示例: ![](https://source.mkshell.com/15896134419985.jpg) 另一个需要知道的重要概念是文件描述符。那么什么是文件描述符?是一个非负整数。 ?什么鬼非负整数? 内核在打开现有文件、创建新文件或创建新Socket时返回给进程一个非负整数,这个非负整数就是文件描述符。 你可能听说过,在UNIX中,一切都是文件。内核通过文件描述符引用进程的打开文件。当你需要读或写一个文件时,你可以用文件描述符来识别它。 Python为你提供了处理文件(和socket)的高级对象,你不必直接使用文件描述符来标识文件,但实际上,在UNIX中,文件和socket是通过它们的整数文件描述符来标识的。 ![](https://source.mkshell.com/15896134597934.jpg) 默认情况下,unixshell将文件描述符0分配给进程的标准输入,将文件描述符1分配给进程的标准输出,将文件描述符2分配给标准错误。 ![](https://source.mkshell.com/15896134822267.jpg) 如前所述,尽管Python提供了一个高级文件或类似文件的对象,但你始终可以对该对象使用 fileno() 方法来获取与该文件关联的文件描述符。回到Python shell,看看鱼们如何做到这一点: ``` >>> import sys >>> sys.stdin ', mode 'r' at 0x102beb0c0> >>> sys.stdin.fileno() 0 >>> sys.stdout.fileno() 1 >>> sys.stderr.fileno() 2 ``` 在Python中处理文件和Socket时,通常会使用高级文件/Socket对象,但有时可能需要直接使用文件描述符。 下面是一个例子,说明了如何使用以文件描述符整数为参数的write系统调用将字符串写入标准输出: ``` >>> import sys >>> import os >>> res = os.write(sys.stdout.fileno(), 'hello\n') hello ``` 这里有一个有趣的事,不过对你来说不再奇怪,因为你已经知道所有东西都是Unix中的一个文件,你的socket也有一个与之相关的文件描述符。同样,在Python中创建一个socket时,会返回一个对象,而不是一个非负整数,但你始终可以使用前面提到的fileno() 方法直接访问socket的整数文件描述符。 ``` >>> import socket >>> sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) >>> sock.fileno() 3 ``` 鱼还想提一件事:你是否注意到在“迭代服务器”`webserver3b.py`中,当服务器进程休眠60秒时,你仍然可以使用第二个curl命令连接到服务器?当然,curl没有立即输出任何内容,它只是挂在那里,但为什么服务器当时不接受连接,客户端也没有立即被拒绝,而是能够连接到服务器?答案是socket对象的listen方法及其BACKLOG参数,鱼在代码中称之为`REQUEST-QUEUE-SIZE`。BACKLOG参数确定内核中传入连接请求的队列大小。当服务器`webserver3b.py`处于睡眠状态时,你运行的第二个curl命令能够连接到服务器,因为内核在服务器套接字的传入连接请求队列中有足够的可用空间。 虽然增加BACKLOG参数并不能神奇地将你的服务器转变为一次可以处理多个客户机请求的服务器,但是对于繁忙的服务器,有一个相当大的backlog参数是很重要的,这样accept调用就不必等待建立新连接,而是可以立即从队列中获取新连接,并立即开始处理客户端请求。 到目前为止,文章以上的内容覆盖了很多知识点,鱼们复习下: ![](https://source.mkshell.com/15896135283993.jpg) - 迭代服务器 - 服务器Socket创建序列 (socket, bind, listen, accept) - 客户端Socket创建序列 (socket, connect) - Socket Pair(套接字对) - Socket - 临时端口和常用端口 - 进程 - 进程id(PID),父进程id(PPID),父子进程关系 - 文件描述符 - BACKLOG参数的含义 ### 使用fork编写并发服务器 现在鱼们已经准备好回答那一个问题:“为了提高你的程序的性能,你如何让你的服务器一次处理多个请求?” 或者鱼们换一个问法:“你怎么写一个并发的服务器呢?” ![](https://source.mkshell.com/15896135527661.jpg) 写一个并发服务器最简单的方法,是在Unix系统下使用fork()系统调用。 ![](https://source.mkshell.com/15896135674072.jpg) 下面是新的闪亮登场的并发服务器的代码,命名为`webserver3c.py`,它可以同时处理多个客户端请求(在鱼们的迭代服务器示例webserver3b.py中,每个子进程睡眠60秒): ![](https://source.mkshell.com/15896135804809.jpg) ``` ########################################################################### # Concurrent server - webserver3c.py # # # # Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X # # # # - Child process sleeps for 60 seconds after handling a client's request # # - Parent and child processes close duplicate descriptors # # # ########################################################################### import os import socket import time SERVER_ADDRESS = (HOST, PORT) = '', 8888 REQUEST_QUEUE_SIZE = 5 def handle_request(client_connection): request = client_connection.recv(1024) print( 'Child PID: {pid}. Parent PID {ppid}'.format( pid=os.getpid(), ppid=os.getppid(), ) ) print(request.decode()) http_response = b"""\ HTTP/1.1 200 OK Hello, World! """ client_connection.sendall(http_response) time.sleep(60) def serve_forever(): listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) listen_socket.bind(SERVER_ADDRESS) listen_socket.listen(REQUEST_QUEUE_SIZE) print('Serving HTTP on port {port} ...'.format(port=PORT)) print('Parent PID (PPID): {pid}\n'.format(pid=os.getpid())) while True: client_connection, client_address = listen_socket.accept() pid = os.fork() if pid == 0: # child listen_socket.close() # close child copy handle_request(client_connection) client_connection.close() os._exit(0) # child exits here else: # parent client_connection.close() # close parent copy and loop over if __name__ == '__main__': serve_forever() ``` 在深入讨论fork是如何工作的之前,鱼们先尝试一下并亲眼确认服务器确实可以同时处理多个客户机请求。 不同于`webserver3a.py`和`webserver3b.py`,使用以下命令行启动服务器: ``` $ python webserver3c.py ``` 在迭代服务器上尝试同样的两个curl命令,然后鱼们可以看到,即使服务器子进程在服务客户机请求后睡眠60秒,它也不会影响其他客户端的请求,因为它们由不同的完全独立的进程提供服务。你应该看到curl命令输出“Hello,World!”马上就hung住60秒。你可以继续运行任意数量的curl命令(当然不能大于fd的上限),所有这些命令都将立即输出服务器的响应“Hello,World”,不会有任何明显的延迟。 关于fork()要注意一点,一个代码里面调用fork一次,它会返回两次:一次在父进程中,一次在子进程中。派生新进程时,返回给子进程的进程ID为0。当fork在父进程中返回时,它返回子进程的PID。 ![](https://source.mkshell.com/15896136260896.jpg) 鱼依稀记得当鱼第一次读到并尝试fork的时候鱼是多么的迷,现在这在鱼看来依然很神奇。 更多地关注[造壳](https://www.mkshell.com)。 当父进程派生新的子进程时,子进程将获取父进程的文件描述符的副本: ![](https://source.mkshell.com/15896136492248.jpg) 你可能注意到上面代码中的父进程关闭了客户端连接: ``` else: # parent client_connection.close() # close parent copy and loop over ``` 那么,当一个子进程的父进程关闭了同一个套接字,它为什么还能从客户端socket里面读取数据呢? 答案如上图所示。内核使用描述符引用计数来决定是否关闭socket。它只在其描述符引用计数变为0时,关闭套接字。 当服务器创建子进程时,子进程获取父进程文件描述符的副本,内核增加这些描述符的引用计数。在一个父进程和一个子进程的情况下,客户端socket的描述符引用计数为2,当上面代码中的父进程关闭客户端连接套接字时,它只会减少其引用计数,该计数将变为1,不足以导致内核关闭socket。 另外,子进程还关闭父进程侦听套接字的副本,因为子进程不关心接受新的客户端连接,它只关心处理来自已建立的客户端连接的请求: ``` listen_socket.close() # close child copy ``` 鱼将在本文后面讨论如果不关闭重复的描述符会发生什么。 从并发服务器的源代码中可以看到,服务器父进程现在的唯一角色是接受一个新的客户端连接,派生一个新的子进程来处理该客户端请求,并循环接受另一个客户端连接,仅此而已。服务器父进程不处理客户端请求,而是让其子进程处理。 鱼们先讨论另一个问题,鱼们所说的两个事件同时发生是什么意思? ![](https://source.mkshell.com/15896136838349.jpg) 当鱼们说两个事件同时发生时,通常是指它们同时发生。这个定义很好,但你应该记住严格的定义: 如果你看不出哪个程序会先发生,那么两个事件是并发的。 再次重申一下,现在是时候回顾一下你迄今为止所涉及的主要思想和概念了。 ![](https://source.mkshell.com/15896136972711.jpg) - 在Unix中编写并发服务器的最简单方法是使用`fork()`系统调用 - 当进程分叉新进程时,它将成为该新分叉子进程的父进程。 - 在调用fork之后,父级和子级共享相同的文件描述符。 - 内核使用描述符引用计数来决定是否关闭文件/socket - 服务器父进程的角色:它现在所做的只是接受来自客户端的新连接,派生子进程来处理客户端请求,然后循环接受新的客户端连接。 ### 回收文件描述符 让鱼们看看如果不关闭父进程和子进程中的重复套接字描述符,将会发生什么。`webserver3d.py`是并发服务器的修改版本,其中服务器不关闭重复的描述符: ``` ########################################################################### # Concurrent server - webserver3d.py # # # # Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X # ########################################################################### import os import socket SERVER_ADDRESS = (HOST, PORT) = '', 8888 REQUEST_QUEUE_SIZE = 5 def handle_request(client_connection): request = client_connection.recv(1024) http_response = b"""\ HTTP/1.1 200 OK Hello, World! """ client_connection.sendall(http_response) def serve_forever(): listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) listen_socket.bind(SERVER_ADDRESS) listen_socket.listen(REQUEST_QUEUE_SIZE) print('Serving HTTP on port {port} ...'.format(port=PORT)) clients = [] while True: client_connection, client_address = listen_socket.accept() # store the reference otherwise it's garbage collected # on the next loop run clients.append(client_connection) pid = os.fork() if pid == 0: # child listen_socket.close() # close child copy handle_request(client_connection) client_connection.close() os._exit(0) # child exits here else: # parent # client_connection.close() print(len(clients)) if __name__ == '__main__': serve_forever() ``` 运行起来: ``` $ python webserver3d.py ``` 使用curl请求服务器: ``` $ curl http://localhost:8888/hello Hello, World! ``` curl打印了并发服务器的响应,但它没有终止并一直挂起。服务器不再休眠60秒:其子进程主动处理客户端请求,关闭客户端连接并退出,但客户端这边的curl仍然没有终止。 ![](https://source.mkshell.com/15896137493219.jpg) 为什么curl不终止?原因是文件描述符还有余。 当子进程关闭客户端连接时,内核减少了该客户端套接字的引用计数,计数变为1。服务器子进程已退出,但客户端套接字未被内核关闭,因为该套接字描述符的引用计数不是0。因此,终止数据包(在TCP/IP术语中称为FIN)未发送到客户端,客户端保持在线。 如果长时间运行的服务器没有关闭重复的文件描述符,它最终将耗尽可用的文件描述符: ![](https://source.mkshell.com/15896137640964.jpg) 使用`Control-C`终止你`webserver3d.py`程序,检查下你所在服务器上的默认可用资源,可以使用`ulimit`命令: ``` $ ulimit -a core file size (blocks, -c) 0 data seg size (kbytes, -d) unlimited scheduling priority (-e) 0 file size (blocks, -f) unlimited pending signals (-i) 3842 max locked memory (kbytes, -l) 64 max memory size (kbytes, -m) unlimited open files (-n) 1024 pipe size (512 bytes, -p) 8 POSIX message queues (bytes, -q) 819200 real-time priority (-r) 0 stack size (kbytes, -s) 8192 cpu time (seconds, -t) unlimited max user processes (-u) 3842 virtual memory (kbytes, -v) unlimited file locks (-x) unlimited ``` 如上所示,在鱼的Ubuntu机器上,服务器进程可以使用的打开文件描述符(打开的文件)的最大数量是1024个。 现在让鱼们看看如果服务器不关闭重复的描述符,它将如何耗尽可用的文件描述符。在现有或新的终端窗口中,将服务器的最大打开文件描述符数设置为256: ``` $ ulimit -n 256 ``` 在同一个终端中启动服务: ``` $ python webserver3d.py ``` 然后使用以下的自动化代码`client3.py`,模拟请求数量比较多的客户端: ``` ##################################################################### # Test client - client3.py # # # # Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X # ##################################################################### import argparse import errno import os import socket SERVER_ADDRESS = 'localhost', 8888 REQUEST = b"""\ GET /hello HTTP/1.1 Host: localhost:8888 """ def main(max_clients, max_conns): socks = [] for client_num in range(max_clients): pid = os.fork() if pid == 0: for connection_num in range(max_conns): sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect(SERVER_ADDRESS) sock.sendall(REQUEST) socks.append(sock) print(connection_num) os._exit(0) if __name__ == '__main__': parser = argparse.ArgumentParser( description='Test client for LSBAWS.', formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) parser.add_argument( '--max-conns', type=int, default=1024, help='Maximum number of connections per client.' ) parser.add_argument( '--max-clients', type=int, default=1, help='Maximum number of clients.' ) args = parser.parse_args() main(args.max_clients, args.max_conns) ``` 在一个新的终端中,启动`client3.py`,请求量设定为300: ``` $ python client3.py --max-clients=300 ``` 很快你的服务器就会爆炸。这是鱼实验时异常的截图: ![](https://source.mkshell.com/15896138418969.jpg) 试验给鱼们带来的教训很清楚:服务器应该关闭重复的描述符。 ### 僵尸进程的危害 但即使你关闭了重复的描述符,你还没有走出困境,因为你的服务器还有一个问题,那就是僵尸进程! ![](https://source.mkshell.com/15896138562899.jpg) 是的,其实你的服务器代码实际上创建了僵尸进程。让鱼们看看怎么做。鱼们先重新启动服务器: ``` $ python webserver3d.py ``` 在另一个终端中,使用curl请求: ``` $ curl http://localhost:8888/hello ``` 现在运行ps命令来显示正在运行的Python进程。这是鱼的Ubuntu上ps输出的例子: ``` $ ps auxw | grep -i python | grep -v grep vagrant 9099 0.0 1.2 31804 6256 pts/0 S+ 16:33 0:00 python webserver3d.py vagrant 9102 0.0 0.0 0 0 pts/0 Z+ 16:33 0:00 [python] ``` 你是否看到上面的第二行,其中显示PID 9102的进程的状态是Z+,进程的名称是?那是鱼们的僵尸进程。甚至鱼们还不能杀死他们。 ![](https://source.mkshell.com/15896138891515.jpg) 即使鱼们使用`kill -9`,他们依然会存在。 什么是僵尸进程?为什么鱼们的服务器要创建僵尸进程? 僵尸进程是已终止的进程,但其父进程尚未等待它,也尚未收到其终止状态。当子进程在其父进程之前退出时,内核会将子进程变成僵尸进程,并存储一些有关该进程的信息,供其父进程以后检索。存储的信息通常是进程ID、进程终止状态和进程的资源使用情况。 所以说,僵尸进程是有目的的,但是如果你的服务器不处理这些僵尸进程,你的系统最终就会被阻塞。 让鱼们试验看看不清理僵尸进程会怎么样。 首先停止正在运行的服务器,并在新的终端窗口中,使用ulimit命令将max user processess设置为400(确保将open files设置为一个很高的数字,也就是说500): ``` $ ulimit -u 400 $ ulimit -n 500 ``` 在刚刚运行`$ulimit-u 400`命令的同一终端中,启动服务器`webserver3d.py`: ``` $ python webserver3d.py ``` 在新的终端窗口中,启动`client3.py`并告诉它创建500个到服务器的同时连接: ``` $ python client3.py --max-clients=500 ``` 很快,你的服务器就会出现一个`OSError:Resource temporary unavailable`异常,当它试图创建一个新的子进程时,创建失败,因为它已经达到了允许创建的最大子进程数的限制。这是鱼机器上异常的截图: ![](https://source.mkshell.com/15896139262257.jpg) 如你所见,如果不处理僵尸进程,就会给长期运行的服务器带来问题。鱼将后面会讨论服务器应该如何处理僵尸问题。 鱼们再回顾一下知识点: ![](https://source.mkshell.com/15896139470204.jpg) - 如果不关闭重复的描述符,则客户端不会终止,因为客户端连接不会关闭。 - 如果不关闭重复的描述符,长期运行的服务器最终将耗尽可用的文件描述符(最大打开文件数)。 - 当派生子进程并退出,而父进程不等待它,也不收集其终止状态时,它将成为一个僵尸进程。 - 僵尸需要吃点东西,在鱼们的例子中,这是记忆。如果服务器不处理僵尸进程,那么它最终将耗尽可用进程(最大用户进程)。 - 你不能杀僵尸,你需要等待。 ### 解决僵尸进程:信号处理程序 + 等待系统调用 那么你需要做什么来处理掉僵尸进程呢?你需要修改服务器代码,以等待僵尸进程,获得其终止状态。然后可以通过修改服务器来调用等待系统调用来完成此操作。 不幸的是,这远不是理想的,因为如果调用wait,将阻塞服务器,从而有效地阻止服务器处理新的客户端连接请求。还有其他选择吗?是的,有,其中一个是信号处理程序和等待系统调用的组合。 ![](https://source.mkshell.com/15896139700455.jpg) 鱼们来看一下工作原理。当子进程退出时,内核发送一个SIGCHLD信号。父进程可以设置一个信号处理程序,以异步通知该SIGCHLD事件,然后它可以等待子进程收集其终止状态,从而防止僵尸进程留在周围。 ![](https://source.mkshell.com/15896139904290.jpg) 顺便说一下,异步事件意味着父进程不能提前知道事件将要发生。 修改服务器代码以设定SIGCHLD事件,并在事件处理程序中等待终止的子进程。修改代码得`webserver3e.py`文件: ``` ########################################################################### # Concurrent server - webserver3e.py # # # # Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X # ########################################################################### import os import signal import socket import time SERVER_ADDRESS = (HOST, PORT) = '', 8888 REQUEST_QUEUE_SIZE = 5 def grim_reaper(signum, frame): pid, status = os.wait() print( 'Child {pid} terminated with status {status}' '\n'.format(pid=pid, status=status) ) def handle_request(client_connection): request = client_connection.recv(1024) print(request.decode()) http_response = b"""\ HTTP/1.1 200 OK Hello, World! """ client_connection.sendall(http_response) # sleep to allow the parent to loop over to 'accept' and block there time.sleep(3) def serve_forever(): listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) listen_socket.bind(SERVER_ADDRESS) listen_socket.listen(REQUEST_QUEUE_SIZE) print('Serving HTTP on port {port} ...'.format(port=PORT)) signal.signal(signal.SIGCHLD, grim_reaper) while True: client_connection, client_address = listen_socket.accept() pid = os.fork() if pid == 0: # child listen_socket.close() # close child copy handle_request(client_connection) client_connection.close() os._exit(0) else: # parent client_connection.close() if __name__ == '__main__': serve_forever() ``` 启动服务器: ``` $ python webserver3e.py ``` 再请求一次: ``` $ curl http://localhost:8888/hello ``` 看服务器终端: ![](https://source.mkshell.com/15896140478063.jpg) 发生了什么呢?accept调用失败了,返回了EINTR错误。 ![](https://source.mkshell.com/15896140636880.jpg) 当子进程退出导致SIGCHLD事件后,父进程accept调用会被阻塞,然后导致激活了信号处理程序,当信号处理程序完成时,accept系统调用被中断: ![](https://source.mkshell.com/15896140782881.jpg) Don’t worry, it’s a pretty simple problem to solve, though. All you need to do is to re-start the accept system call. Here is the modified version of the server webserver3f.py that handles that problem: 不过这是个很简单的问题,只需要重新启动accept系统调用。修改版本`webserver3f.py`解决了这个问题: ``` ########################################################################### # Concurrent server - webserver3f.py # # # # Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X # ########################################################################### import errno import os import signal import socket SERVER_ADDRESS = (HOST, PORT) = '', 8888 REQUEST_QUEUE_SIZE = 1024 def grim_reaper(signum, frame): pid, status = os.wait() def handle_request(client_connection): request = client_connection.recv(1024) print(request.decode()) http_response = b"""\ HTTP/1.1 200 OK Hello, World! """ client_connection.sendall(http_response) def serve_forever(): listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) listen_socket.bind(SERVER_ADDRESS) listen_socket.listen(REQUEST_QUEUE_SIZE) print('Serving HTTP on port {port} ...'.format(port=PORT)) signal.signal(signal.SIGCHLD, grim_reaper) while True: try: client_connection, client_address = listen_socket.accept() except IOError as e: code, msg = e.args # restart 'accept' if it was interrupted if code == errno.EINTR: continue else: raise pid = os.fork() if pid == 0: # child listen_socket.close() # close child copy handle_request(client_connection) client_connection.close() os._exit(0) else: # parent client_connection.close() # close parent copy and loop over if __name__ == '__main__': serve_forever() ``` 启动升级后的`webserver3f.py`: ``` $ python webserver3f.py ``` 请求一下: ``` $ curl http://localhost:8888/hello ``` 现在不再有EINTR异常了。 接下来鱼们再验证是否不再有僵尸进程,以及带有wait调用的SIGCHLD事件处理程序是否处理已终止的子进程。 要做到这一点,只需运行ps命令,并亲自查看不再有Z+状态的Python进程(不再有进程)就ok了。 ![](https://source.mkshell.com/15896141130408.jpg) - 如果你用fork创建一个子进程而不wait它,它就会变成僵尸进程; - 处理僵尸进程的方法:使用SIGCHLD事件处理程序,异步等待已终止的子进程获取其终止状态; - 当使用事件处理程序时,你需要记住系统调用可能会被中断,并且你需要为该场景做好准备。 好吧,到目前为止还不错。没问题吧?好吧,差不多了。再次尝试`webserver3f.py`,但不要使用curl发出一个请求,而是使用`client3.py`创建128个同时连接: ``` $ python client3.py --max-clients 128 ``` 运行下ps命令看下: ``` $ ps auxw | grep -i python | grep -v grep ``` 然而,僵尸进程还在。 ![](https://source.mkshell.com/15896141580510.jpg) 这次是什么问题呢?当你同时运行128个客户端并建立128个连接时,服务器上的子进程处理这些请求并几乎同时退出,导致大量SIGCHLD信号被发送到父进程。问题是,信号没有排队,服务器进程错过了几个信号,导致几个僵尸进程无人值守: ![](https://source.mkshell.com/15896141803908.jpg) 解决这个问题的方法是,设置一个SIGCHLD事件处理程序,但是不要等待,而是在循环中使用带WNOHANG选项的waitpid系统调用,以确保所有终止的子进程都得到处理。以下是修改后的服务器代码`webserver3g.py`: ``` ########################################################################### # Concurrent server - webserver3g.py # # # # Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X # ########################################################################### import errno import os import signal import socket SERVER_ADDRESS = (HOST, PORT) = '', 8888 REQUEST_QUEUE_SIZE = 1024 def grim_reaper(signum, frame): while True: try: pid, status = os.waitpid( -1, # Wait for any child process os.WNOHANG # Do not block and return EWOULDBLOCK error ) except OSError: return if pid == 0: # no more zombies return def handle_request(client_connection): request = client_connection.recv(1024) print(request.decode()) http_response = b"""\ HTTP/1.1 200 OK Hello, World! """ client_connection.sendall(http_response) def serve_forever(): listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) listen_socket.bind(SERVER_ADDRESS) listen_socket.listen(REQUEST_QUEUE_SIZE) print('Serving HTTP on port {port} ...'.format(port=PORT)) signal.signal(signal.SIGCHLD, grim_reaper) while True: try: client_connection, client_address = listen_socket.accept() except IOError as e: code, msg = e.args # restart 'accept' if it was interrupted if code == errno.EINTR: continue else: raise pid = os.fork() if pid == 0: # child listen_socket.close() # close child copy handle_request(client_connection) client_connection.close() os._exit(0) else: # parent client_connection.close() # close parent copy and loop over if __name__ == '__main__': serve_forever() ``` 启动服务: ``` $ python webserver3g.py ``` 发起请求: ``` $ python client3.py --max-clients 128 ``` 现在可以确认没有僵尸进程了。 ![](https://source.mkshell.com/15896142204158.jpg) ## 总结 恭喜!代码的旅途很漫长,但总算结束了。 现在你拥有了自己的简单并发服务器,代码可以作为你进一步面向生产级Web服务器的基础。 接下来是什么?正如乔希·比林斯所说, > “就像一张邮票,坚持一件事,直到你到达那里。” 开始掌握基本知识,质疑你已经知道的,然后总是深入挖掘。 ![](https://source.mkshell.com/15896142358790.jpg) “如果你只学方法,你就会被方法束缚住。但如果你学会了原则,你就可以设计出自己的方法。” —— 爱默生。 下面是鱼为这篇文章中的大部分内容而绘制的书籍列表。它们将帮助你拓宽和加深你对鱼所涉及主题的知识。鱼强烈建议你以某种方式去买那些书:从你的朋友那里借,从你当地的图书馆里看,或者在亚马逊上买: 1. Unix Network Programming, Volume 1: The Sockets Networking API (3rd Edition) 2. Advanced Programming in the UNIX Environment, 3rd Edition 3. The Linux Programming Interface: A Linux and UNIX System Programming Handbook 4. TCP/IP Illustrated, Volume 1: The Protocols (2nd Edition) (Addison-Wesley Professional Computing Series) 5. The Little Book of SEMAPHORES (2nd Edition): The Ins and Outs of Concurrency Control and Common Mistakes. Also available for free on the author’s site here. _先这样吧_ [原文](https://ruslanspivak.com/lsbaws-part1/),若有错误之处请指出,更多地关注[造壳](https://www.mkshell.com)。 https://www.mkshell.com/posts/2019-01-20-mysql-innodb-engine-index-storage-structure/ https://www.mkshell.com/posts/2019-01-20-mysql-innodb-engine-index-storage-structure.md 2019-01-20 # Mysql InnoDB引擎的索引和存储结构 在Oracle 和SQL Server等数据库中只有一种存储引擎,所有数据存储管理机制都是一样的。 而MySql数据库提供了多种存储引擎。用户可以根据不同的需求为数据表选择不同的存储引擎,用户也可以根据自己的需要编写自己的存储引擎。 ## MySQL主要存储引擎的区别 MySQL默认的存储引擎是MyISAM,其他常用的就是InnoDB,另外还有MERGE、MEMORY(HEAP)等。 ### 主要的几个存储引擎 MyISAM管理非事务表,提供高速存储和检索,以及全文搜索能力。 MyISAM是Mysql的默认存储引擎。当create创建新表时,未指定新表的存储引擎时,默认使用MyISAM。每个MyISAM在磁盘上存储成三个文件。文件名都和表名相同,扩展名分别是.frm(存储表定义)、.MYD (MYData,存储数据)、.MYI (MYIndex,存储索引)。数据文件和索引文件可以放置在不同的目录,平均分布io,获得更快的速度。 InnoDB存储引擎用于事务处理应用程序,具有众多特性,包括ACID事务支持,提供了具有提交、回滚和崩溃恢复能力的事务安全。但是对比MyISAM存储引擎,InnoDB写的处理效率差一些并且会占用更多的磁盘空间以保留数据和索引。 Memory将所有数据保存在内存中,可以应用于临时表中在需要快速查找引用和其他类似数据的环境下,可提供极快的访问。Memory使用哈希索引,所以数据的存取速度非常快。 Merge允许MySQL DBA或开发人员将一系列等同的MyISAM表以逻辑方式组合在一起,并作为1个对象引用它们。对于诸如数据仓储等VLDB环境十分适合。 ### 不同存储引擎的横向对比 | 特点 | MyISAM | BDB | Memory | InnoDB | | -------------- | ------ | ---- | ------ | ------ | | 存储限制 | x | x | v | 64TB | | 事务安全 | | v | | v | | 锁机制 | 表锁 | 页锁 | 表锁 | 行锁 | | B树索引 | v | v | v | v | | 哈希索引 | | | v | v | | 全文索引 | v | | | | | 集群索引 | | | | v | | 数据缓存 | | | v | v | | 索引缓存 | v | | v | v | | 数据可压缩 | v | | | | | 空间使用 | 低 | 低 | | 高 | | 内存使用 | 低 | 低 | 中 | 高 | | 批量插入的速度 | 高 | 高 | 高 | 低 | | 支持外键 | | | | v | ### 查看和配置存储引擎的操作 #### 1.用show engines; 命令可以显示当前数据库支持的存储引擎情况; #### 2.要查看表的定义结构等信息可以使用以下几种命令: ``` Desc[ribe] tablename; //查看数据表的结构 Show create table tablename; //显示表的创建语句,可以查看创建表时指定的ENGINE show table status like ‘tablename’\G显示表的当前状态值 ``` #### 3.设置或修改表的存储引擎 创建数据库表时设置存储存储引擎的基本语法是: ``` Create table tableName( columnName(列名1) type(数据类型) attri(属性设置), columnName(列名2) type(数据类型) attri(属性设置), ……..) engine = engineName ``` 修改存储引擎,可以用命令 ``` Alter table tableName engine =engineName ``` 对于整个服务器或方案,你并不一定要使用相同的存储引擎,可以为方案中的每个表使用不同的存储引擎。 ## InnoDB的存储结构 InnoDB使用页面存储结构,下面是InnoDB的表空间结构图: ![](https://source.mkshell.com/15479681638675.jpg) Page页面存储格式如下图所示: ![](https://source.mkshell.com/15479681741263.jpg) 一个页面的存储由以下几部分组成: 1. 页头(Page Header):记录页面的控制信息,共占150字节,包括页的左右兄弟页面指针、页面空间使用情况等,页头的详细说明会在下一篇中描述。 2. 最小虚记录、最大虚记录:两个固定位置存储的虚记录,本身并不存储数据。最小虚记录比任何记录都小,而最大虚记录比任何记录都大。 3. 记录堆(record heap):指上图的橙黄色部分。表示页面已分配的记录空间,也是索引数据的真正存储区域。记录堆分为两种,即有效记录和已删除记录。有效记录就是索引正常使用的记录,而已删除记录表示索引已经删除,不在使用的记录,如上图的深蓝色部分。随着记录的更新和删除越来越频繁,记录堆中已删除记录将会越多,即会出现越来越多的空洞(碎片)。这些已删除记录连接起来,就会成为页面的自由空间链表。 4. 未分配空间:指页面未使用的存储空间,随着页面不断使用,未分配空间将会越来越小。当新插入一条记录时,首先尝试从自由空间链表中获得合适的存储位置(空间足够),如果没有满足的,就会在未分配空间中申请。 5. slot区:slot是一些页面有效记录的指针,每个slot占两个字节,存储了记录相对页面首地址的偏移。如果页面有n条有效记录,那么slot的数量就在n/8+2~n/4+2之间。下一节详细介绍slot区,它是记录页面有序和二分查找的关键。 6. 页尾(Page Tailer):页面最后部分,占8个字节,主要存储页面的校验信息。 页面中的页头,最大/最小虚记录以及页尾都是页面中有固定的存储位置。 ## InnoDB的索引结构 InnoDB使用B+Tree的方式存储索引。 Innodb的一个表可能包含多个索引,每个索引都使用B+树来存储。而索引包括聚集索引和二级索引,聚集索引使用表的主键作为索引键,包含表的所有字段。二级索引只包含索引键和聚集索引键(主键)的内容,不包括其他字段。每一个索引都是一棵B+树,每棵B+树由很多页面组成,而每个页面大小一般为16K。从B+树的组织结构来看,B树的页面可分为: 叶子节点:B树层次为0的页面,存储记录的所有内容。 非叶子节点:B树层次大于0的页面,只存储索引键和页面指针。 一棵典型的B+树结构: ![](https://source.mkshell.com/15479682010568.jpg) 从上图可知,相同层次的页面是用一个双向链表连接起来的。 一般情况下,从B+树的最左边叶子节点开始,一直向右扫描,就可以得到B+树的从小到大的所有数据。因此,对于叶子节点,有如下特征: 页内数据是按索引键排序的。 页面的任一记录的索引键值不小于其左兄弟页面的任何记录。 参考文章:[https://yq.aliyun.com/articles/24249](https://yq.aliyun.com/articles/24249) 原文链接:[http://www.cnblogs.com/binyue/p/4135121.html](http://www.cnblogs.com/binyue/p/4135121.html) https://www.mkshell.com/posts/2018-12-18-python-super-learning/ https://www.mkshell.com/posts/2018-12-18-python-super-learning.md 2018-12-18 # Python super 没那么简单 说到 `super`, 大家可能觉得很简单呀,不就是用来调用父类方法的嘛。如果真的这么简单的话也就不会有这篇文章了,且听我细细道来。😄 ## 约定 在开始之前我们来约定一下本文所使用的 Python 版本。默认用的是 Python 3,也就是说:本文所定义的类都是新式类。如果你用到是 Python 2 的话,记得继承 `object`: ```python # 默认, Python 3 class A: pass # Python 2 class A(object): pass ``` Python 3 和 Python 2 的另一个区别是: Python 3 可以使用直接使用 `super().xxx` 代替 `super(Class, self).xxx` : ```python # 默认,Python 3 class B(A): def add(self, x): super().add(x) # Python 2 class B(A): def add(self, x): super(B, self).add(x) ``` 所以,你如果用的是 Python 2 的话,记得将本文的 `super()` 替换为 `suepr(Class, self)` 。 如果还有其他不兼容 Python 2 的情况,我会在文中注明的。 ## 单继承 在单继承中 `super` 就像大家所想的那样,主要是用来调用父类的方法的。 ```python class A: def __init__(self): self.n = 2 def add(self, m): print('self is {0} @A.add'.format(self)) self.n += m class B(A): def __init__(self): self.n = 3 def add(self, m): print('self is {0} @B.add'.format(self)) super().add(m) self.n += 3 ``` 你觉得执行下面代码后, `b.n` 的值是多少呢? ```python b = B() b.add(2) print(b.n) ``` 执行结果如下: ```python self is <__main__.B object at 0x106c49b38> @B.add self is <__main__.B object at 0x106c49b38> @A.add 8 ``` 这个结果说明了两个问题: 1. `super().add(m)` 确实调用了父类 A 的 `add` 方法。 2. `super().add(m)` 调用父类方法 `def add(self, m)`时, 此时父类中 `self` 并不是父类的实例而是子类的实例, 所以 `b.add(2)` 之后的结果是 5 而不是 4 。 不知道这个结果是否和你想到一样呢?下面我们来看一个多继承的例子。 ## 多继承 这次我们再定义一个 `class C`,一个 `class D`: ```python class C(A): def __init__(self): self.n = 4 def add(self, m): print('self is {0} @C.add'.format(self)) super().add(m) self.n += 4 class D(B, C): def __init__(self): self.n = 5 def add(self, m): print('self is {0} @D.add'.format(self)) super().add(m) self.n += 5 ``` 下面的代码又输出啥呢? ```python d = D() d.add(2) print(d.n) ``` 这次的输出如下: ```python self is <__main__.D object at 0x10ce10e48> @D.add self is <__main__.D object at 0x10ce10e48> @B.add self is <__main__.D object at 0x10ce10e48> @C.add self is <__main__.D object at 0x10ce10e48> @A.add 19 ``` 你说对了吗?你可能会认为上面代码的输出类似: ```python self is <__main__.D object at 0x10ce10e48> @D.add self is <__main__.D object at 0x10ce10e48> @B.add self is <__main__.D object at 0x10ce10e48> @A.add 15 ``` 为什么会跟预期的不一样呢?下面我们将一起来看看 `super`的奥秘。 ## super 是个类 当我们调用 `super()` 的时候,实际上是实例化了一个 `super` 类。你没看错, `super` 是个类,既不是关键字也不是函数等其他数据结构: ```python >>> class A: pass ... >>> s = super(A) >>> type(s) >>> ``` 在大多数情况下, `super` 包含了两个非常重要的信息: 一个 MRO(Method Resolution Order) 列表以及 MRO 中的一个类。当以如下方式调用 `super`时: ```python super(a_type, obj) ``` MRO 列表指的是 `type(obj)` 的 MRO 列表, MRO 中的那个类就是 `a_type` , 同时 `isinstance(obj, a_type) == True` 。 当这样调用时: ```python super(type1, type2) ``` MRO 指的是 `type2` 的 MRO 列表, MRO 中的那个类就是 `type1` ,同时 `issubclass(type2, type1) == True` 。 那么,`super()`实际上做了啥呢?简单来说就是:提供一个 MRO 列表以及一个 MRO 中的类 C , `super()` 将返回一个从 MRO 列表中 C 之后的类中查找方法的对象。 也就是说,查找方式时不是像常规方法一样从所有的 MRO 类中查找,而是从 MRO 列表的 tail 中查找。 举个栗子, 有个 MRO 列表: ```python [A, B, C, D, E, object] ``` 下面的调用: ```python super(C, A).foo() ``` `super` 只会从 C 之后查找,即: 只会在 D 或 E 或 `object`中查找`foo` 方法。 ## 多继承中 super 的工作方式 再回到前面的 ```python d = D() d.add(2) print(d.n) ``` 现在你可能已经有点眉目,为什么输出会是 ```python self is <__main__.D object at 0x10ce10e48> @D.add self is <__main__.D object at 0x10ce10e48> @B.add self is <__main__.D object at 0x10ce10e48> @C.add self is <__main__.D object at 0x10ce10e48> @A.add 19 ``` 了吧 ;) 下面我们来具体分析一下: - D 的 MRO 是: `[D, B, C, A, object]`。 备注: 可以通过 `D.mro()` (Python 2 使用 `D.__mro__` ) 来查看 D 的 MRO 信息) - 详细的代码分析如下: ```python class A: def __init__(self): self.n = 2 def add(self, m): # 第四步 # 来自 D.add 中的 super # self == d, self.n == d.n == 5 print('self is {0} @A.add'.format(self)) self.n += m # d.n == 7 class B(A): def __init__(self): self.n = 3 def add(self, m): # 第二步 # 来自 D.add 中的 super # self == d, self.n == d.n == 5 print('self is {0} @B.add'.format(self)) # 等价于 suepr(B, self).add(m) # self 的 MRO 是 [D, B, C, A, object] # 从 B 之后的 [C, A, object] 中查找 add 方法 super().add(m) # 第六步 # d.n = 11 self.n += 3 # d.n = 14 class C(A): def __init__(self): self.n = 4 def add(self, m): # 第三步 # 来自 B.add 中的 super # self == d, self.n == d.n == 5 print('self is {0} @C.add'.format(self)) # 等价于 suepr(C, self).add(m) # self 的 MRO 是 [D, B, C, A, object] # 从 C 之后的 [A, object] 中查找 add 方法 super().add(m) # 第五步 # d.n = 7 self.n += 4 # d.n = 11 class D(B, C): def __init__(self): self.n = 5 def add(self, m): # 第一步 print('self is {0} @D.add'.format(self)) # 等价于 super(D, self).add(m) # self 的 MRO 是 [D, B, C, A, object] # 从 D 之后的 [B, C, A, object] 中查找 add 方法 super().add(m) # 第七步 # d.n = 14 self.n += 5 # self.n = 19 d = D() d.add(2) print(d.n) ``` 调用过程图如下: ```python D.mro() == [D, B, C, A, object] d = D() d.n == 5 d.add(2) class D(B, C): class B(A): class C(A): class A: def add(self, m): def add(self, m): def add(self, m): def add(self, m): super().add(m) 1.---> super().add(m) 2.---> super().add(m) 3.---> self.n += m self.n += 5 <------6. self.n += 3 <----5. self.n += 4 <----4. <--| (14+5=19) (11+3=14) (7+4=11) (5+2=7) ``` 现在你知道为什么 `d.add(2)` 后 `d.n` 的值是 19 了吧 ;) That's all! 希望这篇文章能对你有所帮助 ;) 原文链接:[https://mozillazg.com/2016/12/python-super-is-not-as-simple-as-you-thought.html](https://mozillazg.com/2016/12/python-super-is-not-as-simple-as-you-thought.html) https://www.mkshell.com/posts/2018-11-19-python-learning-context-manager/ https://www.mkshell.com/posts/2018-11-19-python-learning-context-manager.md 2018-11-19 # 《Python有什么好学的》之上下文管理器 “Python有什么好学的”这句话可不是反问句,而是问句哦。 主要是煎鱼觉得太多的人觉得Python的语法较为简单,写出来的代码只要符合逻辑,不需要太多的学习即可,即可从一门其他语言跳来用Python写(当然这样是好事,谁都希望入门简单)。 于是我便记录一下,如果要学Python的话,到底有什么好学的。记录一下Python有什么值得学的,对比其他语言有什么特别的地方,有什么样的代码写出来更Pythonic。一路回味,一路学习。 ## 引上下文管理器 太极生两仪,两仪为阴阳。 道有阴阳,月有阴晴,人有生死,门有开关。 你看这个门,它能开能关,就像这个对象,它能创建能释放。(扯远了 编程这行,几十年来都绕不开内存泄露这个问题。内存泄露的根本原因,就是把某个对象创建了,但是却没有去释放它。直到程序结束前那一刻,这个未被释放的对象还一直占着内存,即使程序已经不用这个对象了。泄露的量少的话还好,量大的话就直接打满内存,然后程序就被kill了。 聪明的程序员经过了这十几年的努力,创造出很多高级编程语言,这些编程语言已经不再需要让程序员过度关注内存的问题了。但是在编程时,一些常见的对象释放、流关闭还是要程序员显式地写出来。 最常见的就是文件操作了。 ## 常见的文件操作方式 原始的Python文件操作方式,很简单,也很common(也很java): ```python def read_file_1(): f = open('file_demo.py', 'r') try: print(f.read()) except Exception as e: pass f.close() ``` 就是这么简简单单的,先open然后读写再close,中间读写加个异常处理。 其中close就是释放资源了,在这里如果不close,可能: 1. 资源不释放,直到不可控的垃圾回收来了,甚至直到程序结束 2. 中间对文件修改时,修改的信息还没来得及写入文件 3. 整个代码显得不规范 因此写上close函数理论上已经必须的了,可是`xxx.close()`这样写上去,在逻辑复杂的时候让人容易遗漏,同时也显得不雅观。 这时,各种语言生态有各种解决方案。 像Java,就直接jvm+依赖注入,直接把对象的生命周期管理接管了,只留下对象的使用功能给程序员;像golang,defer一下就好。而python最常用的则是with,即上下文管理器 ## 使用上下文管理器 用with之后的文件读写会变成: ```python def read_file_2(): with open('file_demo.py', 'r') as f: print(f.read()) ``` 我们看到用了with之后,代码没有了open创建,也没有了close释放。而且也没有了异常处理,这样子我们一看到代码,难免会怀疑它的健壮性。 为了更好地理解上下文管理器,我们先实现试试。 ## 实现上下文管理器 我们先感性地对with进行猜测。 从调用with的形式上看,with像是一个函数,包裹住了open和close: ```python # 大概意思而已 with = open + do + close def with(): open(xxxx) doSomething(xxxx) close(xxxx) ``` 而Python的库中已有的方案(contextmanager)也和上面的伪代码具有一定的相似性: ```python from contextlib import contextmanager @contextmanager def c(s): print(s + 'start') yield s print(s + 'end') ``` “打印start”相当于open,而“打印end”相当于close,yield语法和修饰器(@)不熟悉的同学可以复习一下这些文章:[生成器](https://www.mkshell.com/python-learning-generator-iterator/)和[修饰器](https://www.mkshell.com/python-learning-decorator/)。 然后我们调用这个上下文管理器试试,注意煎鱼还给上下文管理器加了参数s,输出的时候会带上: ```python def test_context(): with c('123') as cc: print('in with') print(type(cc)) if __name__ == '__main__': test_context() ``` ![](https://source.mkshell.com/15425620119555.jpg) 我们看到,start和end前都有实参s=123。 现实一个上下文管理器就是这么简单。 ### 异常处理 但是我们必须要注重异常处理,假如上面的上下文管理器中抛异常了怎么办呢: ```python def test_context(): with c('123') as cc: print('in with') print(type(cc)) raise Exception ``` 结果: ![](https://source.mkshell.com/15425622234213.jpg) 显然,这样弱鸡的异常处理,煎鱼时忍不了的。而且最重要的是,后面的close释放居然没有执行! 我们可以在实现上下管理器时,接入异常处理: ```python @contextmanager def c(): print('start') try: yield finally: print('end') def test_except(): try: with c() as cc: print('in with') raise Exception except: print('catch except') ``` 调用test_except函数输出: ![](https://source.mkshell.com/15425626029832.jpg) 我们在上下文管理器的实现中加入了try-finally,保证出现异常的时候,上下文管理器也能执行close。同时在调用with前也加入try结构,保证整个函数的正常运行。 然而,加入了这些东西之后,整个函数变得复杂又难看。 因此,煎鱼觉得,想要代码好看,抽象的逻辑需要再次升华,即从函数的层面升为对象(类)的层面。 ## 实现上下文管理器类 其实用类实现上下文管理器,从逻辑理解上简单了很多,而且不需要引入那一个库: ```python class ContextClass(object): def __init__(self, s): self.s = s def __enter__(self): print(self.s + 'call enter') return self def __exit__(self, exc_type, exc_val, exc_tb): print(self.s + 'call exit') def test(self): print(self.s + 'call test') ``` 从代码的字面意思上,我们就能感受得出来,`__enter__`即为我们理解的open函数,`__exit__`就是close函数。 接下来,我们调用一下这个上下文管理器: ```python def test_context(): with ContextClass('123') as c: print('in with') c.test() print(type(c)) print(isinstance(c, ContextClass)) print('') c = ContextClass('123') print(type(c)) print(isinstance(c, ContextClass)) if __name__ == '__main__': test_context() ``` 输出结果: ![](https://source.mkshell.com/15425630649843.jpg) 功能上和直接用修饰器一致,只是在实现的过程中,逻辑更清晰了。 ### 异常处理 回到我们原来的话题:异常处理。 直接用修饰器实现的上下文管理器处理异常时可以说是很难看了,那么我们的类选手表现又如何呢? 为了方便比较,煎鱼把未进行异常处理的和已进行异常处理的一起写出来,然后煎鱼调用一个不存在的方法来抛异常: ```python class ContextClass(object): def __init__(self, s): self.s = s def __enter__(self): print(self.s + 'call enter') return self def __exit__(self, exc_type, exc_val, exc_tb): print(self.s + 'call exit') class ContextExceptionClass(object): def __init__(self, s): self.s = s def __enter__(self): print(self.s + 'call enter') return self def __exit__(self, exc_type, exc_val, exc_tb): print(self.s + 'call exit') return True def test_context(): with ContextExceptionClass('123') as c: print('in with') t = c.test() print(type(t)) # with ContextClass('456') as c: # print('in with') # t = c.test() # print(type(t)) if __name__ == '__main__': test_context() ``` 输出不一样的结果: ![](https://source.mkshell.com/15425635741507.jpg) ![](https://source.mkshell.com/15425635915235.jpg) 结果发现,看了半天,两个类只有最后一句不一样:异常处理的类中`__exit__`函数多一句返回,而且还是return了True。 而且这两个类都完成了open和close两部,即使后者抛异常了。 而在`__exit__`中加`return True`的意思就是不把异常抛出。 如果想要详细地处理异常,而不是像上面治标不治本的隐藏异常,则需要在`__exit__`函数中处理异常即可,因为该函数中有着异常的信息。 不信?稍微再改改: ```python class ContextExceptionClass(object): def __init__(self, s): self.s = s def __enter__(self): print(self.s + 'call enter') return self def __exit__(self, exc_type, exc_val, exc_tb): print(self.s + 'call exit') print(str(exc_type) + ' ' + str(exc_val) + ' ' + str(exc_tb)) return True ``` 输出与预期异常信息一致: ![](https://source.mkshell.com/15425639308612.jpg) _先这样吧_ 若有错误之处请指出,更多地请关注[造壳](https://www.mkshell.com)。 https://www.mkshell.com/posts/2018-11-07-python-learning-decorator/ https://www.mkshell.com/posts/2018-11-07-python-learning-decorator.md 2018-11-07 # 《Python有什么好学的》之修饰器 “Python有什么好学的”这句话可不是反问句,而是问句哦。 主要是煎鱼觉得太多的人觉得Python的语法较为简单,写出来的代码只要符合逻辑,不需要太多的学习即可,即可从一门其他语言跳来用Python写(当然这样是好事,谁都希望入门简单)。 于是我便记录一下,如果要学Python的话,到底有什么好学的。记录一下Python有什么值得学的,对比其他语言有什么特别的地方,有什么样的代码写出来更Pythonic。一路回味,一路学习。 ## 什么是修饰器,为什么叫修饰器 修饰器英文是Decorator, 我们假设这样一种场景:古老的代码中有几个很是复杂的函数F1、F2、F3...,复杂到看都不想看,反正我们就是不想改这些函数,但是我们需要改造加功能,在这个函数的前后加功能,这个时候我们很容易就实现这个需求: ```python def hi(): """hi func,假装是很复杂的函数""" return 'hi' def aop(func): """aop func""" print('before func') print(func()) print('after func') if __name__ == '__main__': aop(hi) ``` ![](https://source.mkshell.com/15413391330336.jpg) 以上是很是简单的实现,利用Python参数可以传函数引用的特性,就可以实现了这种类似AOP的效果。 这段代码目前没有什么问题,接下来煎鱼加需求:需求为几十个函数都加上这样的前后的功能,而所有调用这些函数地方也要相应地升级。 看起来这个需求比较扯,偏偏这个需求却是较为广泛:在调用函数的前后加上log输出、在调用函数的前后计算调用时间、在调用函数的前后占用和释放资源等等。 一种比较笨的方法就是,为这几十个函数逐一添加一个入口函数,针对a函数添加一个a_aop函数,针对b函数添加一个b_aop函数...如此这样。问题也很明显: 1. 工作量大 2. 代码变得臃肿复杂 3. 原代码有多处调用了这些函数,可以会升级不完全 于是接下来有请修饰器出场,修饰器可以统一地给这些函数加这样的功能: ```python def aop(func): """aop func""" def wrapper(): """wrapper func""" print('before func') func() print('after func') return wrapper @aop def hi(): """hi func""" print('hi') @aop def hello(): """hello func""" print('hello') if __name__ == '__main__': hi() hello() ``` ![](https://source.mkshell.com/15413396962142.jpg) 以上aop函数就是修饰器的函数,使用该修饰器时只要在待加函数上一行加`@修饰器函数名`即可,如实例代码中就是`@aop`。 加上了`@aop`后,调用新功能的hi函数就喝原来的调用一样:就是`hi()`而不是`aop(hi)`,也意味着所有调用这些函数的地方不需要修改就可以升级。 简单地来说,大概修饰器就是以上的这样子。 ## @是个什么 对于新手来说,上面例子中,`@`就是一样奇怪的东西:为什么这样子用就可以实现煎鱼需求的功能了。 其实我们还可以不用`@`,煎鱼换一种写法: ```python def hi(): """hi func""" print('hi') def aop(func): """aop func""" def wrapper(): """wrapper func""" print('before func') func() print('after func') return wrapper if __name__ == '__main__': hi() print('') hi = aop(hi) hi() ``` ![](https://source.mkshell.com/15413411757415.jpg) 上面的例子中的aop函数就是之前说过的修饰器函数。 如例子main函数中第一次调用hi函数时,由于hi函数没叫修饰器,因此我们可以从输出结果中看到程序只输出了一个hi而没有前后功能。 然后煎鱼加了一个`hi = aop(hi)`后再调用hi函数,得到的输出结果和加修饰器的一样,换言之: `@aop` 等效于`hi = aop(hi)` 因此,我们对于`@`,可以理解是,它通过闭包的方式把新函数的引用赋值给了原来函数的引用。 有点拗口。`aop(hi)`是新函数的引用,至于返回了引用的原因是aop函数中运用闭包返回了函数引用。而`hi`这个函数的引用,本来是指向旧函数的,通过`hi = aop(hi)`赋值后,就指向新函数了。 ## 被调函数加参数 以上的例子中,我们都假设被调函数是无参的,如hi、hello函数都是无参的,我们再看一眼煎鱼刚才的写的修饰器函数: ```python def aop(func): """aop func""" def wrapper(): """wrapper func""" print('before func') func() print('after func') return wrapper ``` 很明显,闭包函数wrapper中,调用被调函数用的是`func()`,是无参的。同时就意味着,如果func是一个带参数的函数,再用这个修饰器就会报错。 ```python @aop def hi_with_deco(a): """hi func""" print('hi' + str(a)) if __name__ == '__main__': # hi() hi_with_deco(1) ``` ![](https://source.mkshell.com/15413426217736.jpg) 就是参数的问题。这个时候,我们把修饰器函数改得通用一点即可,其中import了一个函数(也是修饰器函数): ```python from functools import wraps def aop(func): """aop func""" @wraps(func) def wrap(*args, **kwargs): print('before') func(*args, **kwargs) print('after') return wrap @aop def hi(a, b, c): """hi func""" print('test hi: %s, %s, %s' % (a, b, c)) @aop def hello(a, b): """hello func""" print('test hello: %s, %s' % (a, b)) if __name__ == '__main__': hi(1, 2, 3) hello('a', 'b') ``` ![](https://source.mkshell.com/15413427947406.jpg) 这是一种很奇妙的东西,就是在写修饰器函数的时候,还用了别的修饰器函数。那也没什么,毕竟修饰器函数也是函数啊,有什么所谓。 ## 带参数的修饰器 思路到了这里,煎鱼不禁思考一个问题:修饰器函数也是函数,那函数也是应该能传参的。函数传参的话,不同的参数可以输出不同的结果,那么,修饰器函数传参的话,不同的参数会怎么样呢? 其实很简单,修饰器函数不同的参数,能生成不同的修饰器啊。 如,我这次用这个修饰器是把时间日志打到`test.log`,而下次用修饰器的时候煎鱼希望是能打到`test2.log`。这样的需求,除了写两个修饰器函数外,还可以给修饰器加参数选项: ```python from functools import wraps def aop_with_param(aop_test_str): def aop(func): """aop func""" @wraps(func) def wrap(*args, **kwargs): print('before ' + str(aop_test_str)) func(*args, **kwargs) print('after ' + str(aop_test_str)) return wrap return aop @aop_with_param('abc') def hi(a, b, c): """hi func""" print('test hi: %s, %s, %s' % (a, b, c)) @aop_with_param('pppppp') def hi2(a, b, c): """hi func""" print('test hi: %s, %s, %s' % (a, b, c)) if __name__ == '__main__': hi(1, 2, 3) print('') hi2(2, 3, 4) ``` ![](https://source.mkshell.com/15413437338294.jpg) 同样的,可以加一个参数,也可以加多个参数,这里就不说了。 ## 修饰器类 大道同归,逻辑复杂了之后,人们都喜欢将**函数**的思维层面抽象上升到**对象**的层面。原因往往是对象能拥有多个函数,对象往往能管理更复杂的业务逻辑。 显然,修饰器函数也有对应的修饰器类。写起来也没什么难度,和之前的生成器一样简单: ```python from functools import wraps class aop(object): def __init__(self, aop_test_str): self.aop_test_str = aop_test_str def __call__(self, func): @wraps(func) def wrapper(*args, **kwargs): print('before ' + self.aop_test_str) func() print('after ' + self.aop_test_str) return wrapper @aop('pppppp') def hi(): print('hi') ``` 看得出来,这个修饰器类也不过是多了个`__call__`函数,而这个`__call__`函数的内容和之前写的修饰器函数一个样!而使用这个修饰器的方法,和之前也一样,一样的如例子中的`@aop('pppppp')`。 甚至,煎鱼过于无聊,还试了一下继承的修饰器类: ```python class sub_aop(aop): def __init__(self, sub_aop_str, *args, **kwargs): self.sub_aop_str = sub_aop_str super(sub_aop, self).__init__(*args, **kwargs) def __call__(self, func): @wraps(func) def wrapper(*args, **kwargs): print('before ' + self.sub_aop_str) super(sub_aop, self).__call__(func)() print('after ' + self.sub_aop_str) return wrapper @sub_aop('ssssss', 'pppppp') def hello(): print('hello') if __name__ == '__main__': hello() ``` 你们猜猜结果怎么样? _先这样吧_ 若有错误之处请指出,更多地请关注[造壳](https://www.mkshell.com)。 https://www.mkshell.com/posts/2018-11-07-python-learning-generator-iterator/ https://www.mkshell.com/posts/2018-11-07-python-learning-generator-iterator.md 2018-11-07 # 《Python有什么好学的》之生成器/迭代器 “Python有什么好学的”这句话可不是反问句,而是问句哦。 主要是煎鱼觉得太多的人觉得Python的语法较为简单,写出来的代码只要符合逻辑,不需要太多的学习即可,即可从一门其他语言跳来用Python写(当然这样是好事,谁都希望入门简单)。 于是我便记录一下,如果要学Python的话,到底有什么好学的。记录一下Python有什么值得学的,对比其他语言有什么特别的地方,有什么样的代码写出来更Pythonic。一路回味,一路学习。 ## 为什么是斐波那契 谈到生成器/迭代器,人们总是喜欢用斐波那契数列来举例。 ``` 斐波那契数列,数学表示为a(1)=0, a(2)=1, a(i)=a(i-1)+a(i-2) (i>=3): 0 1 1 2 3 5 8 13 21 ... 用一句话说,就是第三位数起,当前这位数是前两位的和。 ``` 当然,使用斐波那契来举例是一个很合适的选择。 那么,为什么说到生成器/迭代器就喜欢斐波那契,而不是其他呢? 斐波那契数列有一个特征:当前这位数的值,可以通过前两位数的值推导出来。比如,我知道了第n位数是5,第n+1位数是8,那我就可以轻易地得出,第n+2位必然是5+8=13。 即,斐波那契数是可以通过推导式得出的。 就是这样一种类似于冥冥注定的感觉:当前的平行空间,是由你之前的选择决定的。而且是钦定好的,你接下来的每一步,其实都已经被决定了,由什么决定呢,由你以前走过的路决定的。 那么,换句话来说,即能由推导式得出的数列,其实都可以用来做生成器/迭代器的例子。例如,煎鱼用一条式子`y(n)=y(n-1)^2 + 1 (n>=1), y(1)=1`,一样能拿来当例子。 既然这样,那就用`y(n)=y(n-1)^2 + 1 (n>=1), y(1)=1`吧。 ## 原始低效的循环 一开始,人们的思想很简单,如果要求`y(n)=y(n-1)^2 + 1 (n>=1), y(1)=1`中y数列的第13位,即y(13),那很简单,就轮询到13个就好了。 ```python def y(): y_current = 1 n = 1 while n < 13: y_current = y_current ** 2 + 1 n += 1 print(n, y_current) ``` 输出也挺长的,就截个图算了: ![](https://source.mkshell.com/15407361247324.jpg) 这个时候,这个代码是完全够用的。接下来,煎鱼加点需求(PM般的狞笑): 1. 暴露n的值,n值可以作为参数输入,即我需要控制数列计算到哪 2. 函数返回(输出)整个数列 接下来,函数改成: ```python def y(n_max): y_current = 0 n = 0 ret_list = [] while n < n_max: y_current = y_current ** 2 + 1 n += 1 ret_list.append(y_current) return ret_list if __name__ == '__main__': for i in y(13): print(i) ``` 看起来没什么毛病,完全符合要求。但是,问题出现在当函数的参数n_max较大的时候。要多大呢,煎鱼尝试输出n_max=13000000,即: ```python if __name__ == '__main__': for i in y(13000000): print(i) ``` 我们看得出来,这个函数的计算量十分大,以煎鱼当前的电脑配置(Macbook pro 2017 i5 8G RAM),等了一两分钟还没结束,只好强行中断了。 ![](https://source.mkshell.com/15407448285844.jpg) 程序为什么卡那么久呢。因为在函数的逻辑中,程序试图将13000000个值都计算出来再返回list以供外接轮询,而且这13000000个一个比一个难算(越来越大,指数增长)。同时,该list也占了庞大的内存空间。 到了这个时候,煎鱼终于要引入生成器了。 ## 生成器的小试牛刀 其实煎鱼就加入了一个yield,并稍作修改: ```python def y_with_yield(n_max): y_current = 0 n = 0 while n < n_max: y_current = y_current ** 2 + 1 n += 1 yield y_current if __name__ == '__main__': for i in y_with_yield(13000000): print(i) ``` ![](https://source.mkshell.com/15407448531582.jpg) 虽然屏幕滚动得很慢,但是起码是在实时地滚动的。 加入了yield变成这样,其实就是搞成了一个简单的生成器。在这里,生成器的作用有: 1. `for i in y_with_yield(13000000)`的循环中,每一次循环程序才会进入函数去计算,而不会把全部结果都计算出来再返回 2. 由于不会把全部结果都计算出来再返回,程序运行所需的内存也大幅地减少 暂时给出初步结论: 1. 这个小生成器在较大数据的计算量时,有较大的优势 2. 程序把推导式的计算通过yield分散了,降低了cpu和内存的压力 3. 如果没有煎鱼的需求,这一切都白搭且多余 我们再来看下生成器的其他用途吧。 ## 通过缓存机制读取文件 在读文件或处理文件时使用缓存是很有必要的,因为我们总是不知道文件会有多大,文件的大小会不会把程序给拖垮。 煎鱼新建一个文件(假设叫test.txt),并往其中写入文本,运行以下代码: ```python def read_file(file_path): BLOCK_SIZE = 100 with open(file_path, 'rb') as f: while True: block = f.read(BLOCK_SIZE) if block: yield block else: return if __name__ == '__main__': for i in read_file('./test.txt'): print(i) print('--------------block-split--------------') ``` 我们把100个长度分为一个block,这个block就相当于我们的缓存:先从文件中读100个,然后让程序处理这100个字符(此处处理为print),再读下一个100。其中block-split的输出是为了让我们更好地辩识出block的头尾。 ![](https://source.mkshell.com/15407471444959.jpg) ## 生成器类和生成器对象 通过yield瞎搞出来的简易生成器有一个很大的限制,就是必须要在循环内。 虽然“迭代”和“循环”有关联,但是当生成器的生成逻辑无比复杂时,比如“推导”的方法已经无法用数学推导式表达时,或者某种场景下的业务逻辑比较复杂以至于无法直接通过循环表达时,生成器类来了。 生成器类看起来很简单,其实就是将煎鱼在上面写的简单生成器写成一个类。 重点就是,我们得找到“推导”,推导在这里是指next函数 —— 我们实现的生成器类最重要的就是next()。 我们来实现上面的y函数的生成器类: ```python class Y(object): def __init__(self, n_max): self.n_max = n_max self.n = 0 self.y = 0 def __iter__(self): return self def next(self): if self.n < self.n_max: self.y = self.y ** 2 + 1 self.n += 1 return self.y raise StopIteration() if __name__ == '__main__': y = Y(13) for i in y: print(i) ``` 有几点是需要注意的: 1. 类需要包含`__iter__()`函数,而返回的不一定是self,但是需要生成器 2. 实现next方法,“迭代”的逻辑请放到该函数里面 3. 如果需要引入别的库或者写别的函数,可以在类中随便加 接下来,煎鱼带来一段很无聊的表演,来表示`__iter__()`函数而返回的不一定是self: ```python class SuperY(object): def __init__(self, n_max): self.n_max = n_max def __iter__(self): return Y(self.n_max) if __name__ == '__main__': sy = SuperY(13) for i in sy: print(i) ``` 这段代码的输出和上一段一模一样。 ## 生成器的照妖镜 这里照妖镜的意思,指一个能鉴别某对象(甚至不是对象)是否一个生成器的东西。 说起来,可能会有点多余而且零碎。 其中有三个函数: 1. isgeneratorfunction(),字面意思,是否生成器函数 2. isgenerator(),还是字面意思,是否生成器 3. isinstance(),这个指某对象是否为某个类的实例 我们把前面写过的y(带yield的函数),和Y(生成器类)导入后,进行实验观察: ``` from use_yield import y_with_yield as y from iter_obj import Y from inspect import isgeneratorfunction, isgenerator from types import GeneratorType from collections import Iterator if __name__ == '__main__': print(isgeneratorfunction(y)) # True print(isgeneratorfunction(Y)) # False print(isgeneratorfunction(y(5))) # False print(isgeneratorfunction(Y(5))) # False print(isgenerator(y)) # False print(isgenerator(Y)) # False print(isgenerator(y(5))) # True print(isgenerator(Y(5))) # False print("") print(isinstance(y, GeneratorType)) # False print(isinstance(y(5), GeneratorType)) # True print(isinstance(Y, GeneratorType)) # False print(isinstance(Y(5), GeneratorType)) # False print("") print(isinstance(y, Iterator)) # False print(isinstance(y(5), Iterator)) # True print(isinstance(Y, Iterator)) # False print(isinstance(Y(5), Iterator)) # True ``` 实验的结论为: 1. 带yield的y函数是一个生成器函数,带yield的y函数带上参数5后,称为了生成器。因为y是函数的引用,而带上了参数5后,y(5)就不再是函数了,而是通过yield进化成了生成器。 2. 生成器是类GeneratorType的实例,但很遗憾的是,生成器类的实例不是生成器(黑人问号)。 3. 然而,生成器y(5)和生成器类的实例都属于迭代器。 ## 其他乱七八糟的 Python里面,range和xrange有什么不同,用哪个更好,为什么? 对的,就是和生成器有关系,嘿嘿。 _先这样吧_ 若有错误之处请指出,更多地请关注[造壳](https://www.mkshell.com)。 https://www.mkshell.com/posts/2018-09-15-handle-reference-point-object/ https://www.mkshell.com/posts/2018-09-15-handle-reference-point-object.md 2018-09-15 # 句柄、引用、指针与对象 首先要分清是在什么地方讨论,在以前学C时,是这么区分: 1. 指针是对象在内存中的地址; 2. 引用是对象的别名,其实质就是功能受限但是安全性更高的指针; 3. 句柄是指针的指针,句柄实际上是一个数据,是一个Long (整长型)的数据。句柄是一个标识符,是拿来标识对象或者项目的,它就象我们的姓名一样。 Windows是一个以虚拟内存为基础的操作系统。在这种系统环境下,Windows内存管理器经常在内存中来回移动对象,依此来满足各种应用程序的内存需要。对象被移动意味着它的地址变化了。如果地址总是如此变化,我们该到哪里去找该对象呢?为了解决这个问题,Windows操作系统为各应用程序腾出一些内存储地址,用来专门登记各应用对象在内存中的地址变化,而这个地址(存储单元的位置)本身是不变的。Windows内存管理器在移动对象在内存中的位置后,把对象新的地址告知这个句柄地址来保存。 然后我们回到JAVA来解释这个问题: Java中有3个数据类型:基本数据类型、引用类型和null类型。其中,引用类型包括类类型(含数组)、接口类型。为了区别引用类型的变量标识符和基本数据类型变量标识符,我们特别的(特意的)使用Handle来称呼引用类型的变量标识符。对象的引用是创建对象时的返回值!引用是new表达式的返回值。new A(); 这里真正创建了一个对象,但我们没有用句柄去持有(hold、拿着、保存)该引用。handle是变量,reference是一种变量值。 ``` A a = new A() ; ``` 1. 句柄a——常见为A a;或A a=new A();前者没有被初始化,后者被手工初始化。(如果a是一个类的成员变量,会被默认初始化。)句柄不同于基本变量的关键,它是一种间接寻址方式。句柄声明常常称为对象声明(如果你感到可能引起混淆,不理睬对象声明的说法好啦)。 2. 创建对象——new A();这才是真正的创建对象。对象一般通过new表达式来创建。 3. 计算new表达式的值:计算new表达式的值的整个过程,在微观上完成了对象的创建和这个对象自己的成员变量的初始化,在宏观上得到new表达式的值,这个值称为新对象的引用; 4. 引用:new A()的值。引用可以简单的看作对象占据内存空间的地址;通过对象的引用,就可以方便的与其他对象区别开来,引用就是对象独特的身份标识。 5. 句柄的初始化:句柄 = 引用;即把引用赋值给句柄,如语句a=new A();完成句柄的初始化后,就可以用句柄遥控对象了。 6. 对于语句A a=new A();是对象创建及对象初始化与句柄的初始化的合并。 https://www.mkshell.com/posts/2018-09-07-strace-diagnose-learning/ https://www.mkshell.com/posts/2018-09-07-strace-diagnose-learning.md 2018-09-07 # 手把手教你用Strace诊断问题 早些年,如果你知道有个 strace 命令,就很牛了,而现在大家基本都知道 strace 了,如果你遇到性能问题求助别人,十有八九会建议你用 strace 挂上去看看,不过当你挂上去了,看着满屏翻滚的字符,却十有八九看不出个所以然。本文通过一个简单的案例,向你展示一下在用 strace 诊断问题时的一些套路。 如下真实案例,如有雷同,实属必然!让我们看一台高负载服务器的 top 结果: ![top](https://source.mkshell.com/top.jpg) 技巧:运行 top 时,按「1」打开 CPU 列表,按「shift+p」以 CPU 排序。 在本例中大家很容易发现 CPU 主要是被若干个 PHP 进程占用了,同时 PHP 进程占用的比较多的内存,不过系统内存尚有结余,SWAP 也不严重,这并不是问题主因。 不过在 CPU 列表中能看到 CPU 主要消耗在内核态「sy」,而不是用户态「us」,和我们的经验不符。Linux 操作系统有很多用来跟踪程序行为的工具,内核态的函数调用跟踪用「strace」,用户态的函数调用跟踪用「ltrace」,所以这里我们应该用「strace」: ``` shell> strace -p ``` 不过如果直接用 strace 跟踪某个进程的话,那么等待你的往往是满屏翻滚的字符,想从这里看出问题的症结并不是一件容易的事情,好在 strace 可以按操作汇总时间: ``` shell> strace -cp ``` 通过「c」选项用来汇总各个操作的总耗时,运行后的结果大概如下图所示: ![strace1](https://source.mkshell.com/strace1.jpg) 很明显,我们能看到 CPU 主要被 clone 操作消耗了,还可以单独跟踪一下 clone: ``` shell> strace -T -e clone -p ``` 通过「T」选项可以获取操作实际消耗的时间,通过「e」选项可以跟踪某个操作: ![strace2](https://source.mkshell.com/strace2.jpg) 很明显,一个 clone 操作需要几百毫秒,至于 clone 的含义,参考 man 文档: > clone() creates a new process, in a manner similar to fork(2). It is actually a library function layered on top of the underlying clone() system call, hereinafter referred to as sys_clone. A description of sys_clone is given towards the end of this page. Unlike fork(2), these calls allow the child process to share parts of its execution context with the calling process, such as the memory space, the table of file descriptors, and the table of signal handlers. (Note that on this manual page, “calling process” normally corresponds to “parent process”. But see the description of CLONE_PARENT below.) 简单来说,就是创建一个新进程。那么在 PHP 里什么时候会出现此类系统调用呢?查询业务代码看到了 exec 函数,通过如下命令验证它确实会导致 clone 系统调用: ``` shell> strace -eclone php -r 'exec("ls");' ``` 最后再考大家一个题:如果我们用 strace 跟踪一个进程,输出结果很少,是不是说明进程很空闲?其实试试 ltrace,可能会发现别有洞天。记住有内核态和用户态之分。 推荐阅读:[strace cheat sheet](https://blog.packagecloud.io/eng/2015/11/15/strace-cheat-sheet/) 原文链接:[https://huoding.com/2015/10/16/474](https://huoding.com/2015/10/16/474) https://www.mkshell.com/posts/2018-07-29-linux-shell-condition-if-a-z/ https://www.mkshell.com/posts/2018-07-29-linux-shell-condition-if-a-z.md 2018-07-29 # Linux shell条件判断if中的-a到-z的意思 `[ -a FILE ]`如果 FILE 存在则为真。 `[ -b FILE ]`如果 FILE 存在且是一个块特殊文件则为真。 `[ -c FILE ]` 如果 FILE 存在且是一个字特殊文件则为真。 `[ -d FILE ]` 如果 FILE 存在且是一个目录则为真。 `[ -e FILE ]` 如果 FILE 存在则为真。 `[ -f FILE ]` 如果 FILE 存在且是一个普通文件则为真。 `[ -g FILE ]`如果 FILE 存在且已经设置了SGID则为真。 `[ -h FILE ]` 如果 FILE 存在且是一个符号连接则为真。 `[ -k FILE ]` 如果 FILE 存在且已经设置了粘制位则为真。 `[ -p FILE ]` 如果 FILE 存在且是一个名字管道(F如果O)则为真。 `[ -r FILE ]` 如果 FILE 存在且是可读的则为真。 `[ -s FILE ]` 如果 FILE 存在且大小不为0则为真。 `[ -t FD ]` 如果文件描述符 FD 打开且指向一个终端则为真。 `[ -u FILE ]` 如果 FILE 存在且设置了SUID (set user ID)则为真。 `[ -w FILE ]` 如果 FILE 如果 FILE 存在且是可写的则为真。 `[ -x FILE ]` 如果 FILE 存在且是可执行的则为真。 `[ -O FILE ]` 如果 FILE 存在且属有效用户ID则为真。 `[ -G FILE ]` 如果 FILE 存在且属有效用户组则为真。 `[ -L FILE ]` 如果 FILE 存在且是一个符号连接则为真。 `[ -N FILE ]` 如果 FILE 存在 and has been mod如果ied since it was last read则为真。 `[ -S FILE ]` 如果 FILE 存在且是一个套接字则为真。 `[ FILE1 -nt FILE2 ]` 如果 FILE1 has been changed more recently than FILE2, or 如果 FILE1 exists and FILE2 does not则为真。 `[ FILE1 -ot FILE2 ]` 如果 FILE1 比 FILE2 要老, 或者 FILE2 存在且 FILE1 不存在则为真。 `[ FILE1 -ef FILE2 ]` 如果 FILE1 和 FILE2 指向相同的设备和节点号则为真。 `[ -o OPTIONNAME ]` 如果 shell选项 “OPTIONNAME” 开启则为真。 `[ -z STRING ]` “STRING” 的长度为零则为真。 `[ -n STRING ] or [ STRING ]` “STRING” 的长度为非零 non-zero则为真。 `[ STRING1 == STRING2 ]` 如果2个字符串相同。 “=” may be used instead of “==” for strict POSIX compliance则为真。 `[ STRING1 != STRING2 ]` 如果字符串不相等则为真。 `[ STRING1 < STRING2 ]` 如果 “STRING1” sorts before “STRING2” lexicographically in the current locale则为真。 `[ STRING1 > STRING2 ]` 如果 “STRING1” sorts after “STRING2” lexicographically in the current locale则为真。 `[ ARG1 OP ARG2 ]`“OP” is one of -eq, -ne, -lt, -le, -gt or -ge. These arithmetic binary operators return true if “ARG1” is equal to, not equal to, less than, less than or equal to, greater than, or greater than or equal to “ARG2”, respectively. “ARG1” and “ARG2” are integers. https://www.mkshell.com/posts/2018-07-29-search-linux-process-msg/ https://www.mkshell.com/posts/2018-07-29-search-linux-process-msg.md 2018-07-29 # 在Linux中追查进程信息 ## 场景复现 **领导:**小鱼啊,你看下这两个容器,明明一样的,怎么就一个能运行指令,另一个一直超时呢? 煎鱼收到了任务后,就立刻进行问题排查,渐渐地查到了原因。 首先,命令发起程序和容器之间的关系如下: ![](https://source.mkshell.com/15328703730249.jpg) 命令发起程序是在容器外的,通过一个库把需要执行的命令推送到容器内,而容器内有一个worker程序收到这个命令推送后,就在容器内执行所要执行的命令。(容器通信自行了解) 一顿`ps -ef`操作对比后。 **煎鱼:**领导,你看,这两个容器虽然都正常启动了,但是其中容器A的worker是正常运行的,而容器B的worker已经没有了,明显是挂了或者根本没有启动。 **领导:**啊?可是我并不知道这个怎么搞啊。那再启动一下不好吗? **[煎鱼](http://www.jianyujianyu.com):**可是我并不知道这个程序的目录是在哪里的。 **领导:**小鱼啊,这不是有另一个正常的容器嘛,你查一下容器的worker是在哪跑起来的不行吗? **煎鱼:**好有道理! ![](https://source.mkshell.com/15328711503695.jpg) 又执行了一遍`ps`, 如这个马赛克图一样,由于程序执行的时候就在程序目录底下,造成worker就偷懒简写了,而前面全写的路径则只是python的路径。 **煎鱼:**这不一样的坑。。 **领导:**我不管,你给我再看看。 **[煎鱼](http://www.jianyujianyu.com):**好吧。 煎鱼想了一下,既然已经有了这个进程的信息,那么我只要将这个进程的信息拿到不就行了吗,这些信息可都在`/proc/[pid]/`里面! 这里的这个对照容器的work进程号是94710,既然执行程序的时候是在程序目录了,那就查这个进程的执行程序目录就找到了: ``` ll /proc/94710/cwd ``` ![](https://source.mkshell.com/15328715379058.jpg) `/proc/[pid]/cwd`是一个link,它链接到执行程序的目录,所以直接进去目录也是可以的。至于执行程序的命令,`ps`里面有,`/proc/[pid]/cmdline`里面也有。 **煎鱼:**擦!原来这么简单的路径啊。。早知道随便找找就好了。。领导你进去这个目录启动这个程序就好了。 **领导:**好的小鱼。咦?不对啊,我已经进去这个目录了,用了这个命令怎么还是不能启动。 **煎鱼:**噢是这样的,容器在启动这个worker程序的时候,还需要添加了几个环境变量再启动worker,你现在还没有所以还不行。 **领导:**那我知道个啥环境变量,我不管! **[煎鱼](http://www.jianyujianyu.com):**好吧,我们来看看有啥环境变量。 ``` strings /proc/94710/environ ``` ![](https://source.mkshell.com/15328721217576.jpg) **领导:**你这个我看不懂啊,像打了马赛克一样。 **[煎鱼](http://www.jianyujianyu.com):**这个就是这个进程启动时刻的所有环境变量,你把缺了的加进去,再执行程序的启动命令就行。 **领导:**好吧。 于是在领导的“请求”下,[煎鱼](http://www.jianyujianyu.com)手动打完一个个环境变量并export了,最后“开心地”启动了worker。 ## `/proc`目录 proc是Linux里面特有的伪文件系统,里面的数据存于内存而非外存。 系统中运行的进程都有着对应的文件目录,对应着`/proc/[pid]`,里面存在进程的相关信息,包括以上故事中的程序启动文件目录(`/proc/[pid]/cwd`)、程序启动命令(`/proc/[pid]/cmdline`)、程序启动时刻环境变量(`/proc/[pid]/environ`)等。 还有很多有用的命令。 ### `/proc/[pid]/auxv` `/proc/[pid]/auxv`包含传递给进程的 ELF 解释器信息,格式是每一项都是一个 unsigned long长度的 ID 加上一个 unsigned long 长度的值。最后一项以连续的两个 0x00 开头。 ### `/proc/[pid]/comm` `/proc/[pid]/comm`包含进程的命令名。 ### `/proc/[pid]/exe` `/proc/[pid]/exe`为实际运行程序的符号链接。 ### `/proc/[pid]/maps` `/proc/[pid]/maps`显示进程的内存区域映射信息。 ### `/proc/[pid]/stack` `/proc/[pid]/stack`示当前进程的内核调用栈信息,只有内核编译时打开了`CONFIG_STACKTRACE`编译选项,才会生成这个文件。 ### `/proc/[pid]/statm` `/proc/[pid]/statm`显示进程所占用内存大小的统计信息。包含七个值,度量单位是 page(page大小可通过 getconf PAGESIZE 得到)。 ### `/proc/[pid]/status` `/proc/[pid]/status` 包含进程的状态信息。其很多内容与`/proc/[pid]/stat`和`/proc/[pid]/statm`相同,但是却是以一种更清晰地方式展现出来。 ![](https://source.mkshell.com/15328727622019.jpg) _先这样吧_ 若有错误之处请指出,更多地关注[煎鱼](http://www.jianyujianyu.com)。 https://www.mkshell.com/posts/2018-07-24-linux-pseudoterminal-ssh-telnet/ https://www.mkshell.com/posts/2018-07-24-linux-pseudoterminal-ssh-telnet.md 2018-07-24 # Linux 的伪终端的基本原理及其在远程登录(SSH,telnet等)中的应用 本文介绍了linux中伪终端的创建,介绍了终端的回显、行缓存、控制字符等特性,并在此基础上解释和模拟了telnet、SSH开启远程会话的过程。 ## 轻量级远程登录 之前制作的一块嵌入式板子,安装了嵌入式linux操作系统,可以通过串口(Console)登录。为了方便使用,需要寻找通过网线远程登录的方法。最初的想法是SSH,不过板子的ROM太小,存不了体积庞大庞大的OpenSSH套装。后来换用了telnet,直接拿busybox的telnetd做服务器,效果很好。 后来有一天,发现了Linux中有一个直接建立TCP连接的工具:nc 。在服务端使用`nc -l 端口号`来进行监听,在客户端使用`nc IP地址 端口号`来建立连接。建立连接后,nc会把从stdin读入的字节流发送给另一方,把接收到的字节流写入stdout中。配合方便的管道操作,不正可以将shell的输入/输出传送到远端机器上吗?于是在Ubuntu中实验操作如下(之后发现这种操作叫做“反弹shell”): 打开一个终端A,输入命令 ``` mkfifo /tmp/p # 创建临时管道 sh -i /tmp/p ``` 该命令将bash的标准输入输出与nc的标准输出输入连接起来,并由nc将其与socket连接起来。同时,nc监听2333端口(如果使用小于1024的端口,需要root权限),等待远程连接。现在打开另一个终端B,准备连接: ``` nc localhost 2333 ``` 这时,在终端B中出现了sh的提示符。输入一般的shell命令后可以执行并得到结果。看来linux自带的工具已经灵活、强大到足够搭建一个小型的远程登录系统。这个过程可以使用下面的图来描述: ![](https://source.mkshell.com/15324411936429.jpg) 过tty命令,我们看到,此时的shell并没有一个tty终端。确实,它的标准输入输出都是管道。这会带来一个问题,需要操纵tty的一些命令,比如vi、less、sudo等都无法正常使用(可以动手试试效果怎么样)。更为要命的是,在终端B中按下Ctrl+C这样的控制键,内核把结束信号发送给了客户端nc,而不是远程的程序! ![](https://source.mkshell.com/15324412168736.jpg) Ctrl+C直接杀死nc,结束了会话。对比telnet,我们的登录系统还缺少什么东西。这就是伪终端(pseudoterminal)。 ## 了解伪终端 ### 终端和它的作用 终端(terminal)这个词拥有很多含义,这里尽量将其分开说明。 历史上,终端(有时被成为tty,tele typewriter)是用户访问计算机主机的硬件设备,可以理解为一个显示器和一个键盘的组合。 - 现代Linux里面比较接近此概念的是(一系列)虚拟控制台(virtual console)。在Ubuntu等发行版本中按下Ctrl+Alt+F1(或F2, F3, ...)即可切换到相应控制台下。`/dev/tty1`等文件是这些硬件在linux下的设备文件。程序通过这些文件的读写实现对控制台的读写,通过ioctl实现对硬件参数的设置。 - 终端还可以指代设备文件,实现软件接口。比如常见的`/dev/tty1`文件,还有/dev/pts目录下的所有文件。 - 对终端设备文件进行读写,能够从键盘读取输入,从显示器进行输出,实现交互式的输入输出 - linux中的每个进程有一个“控制终端(control terminal)”的属性(取值为设备文件),用于实现作业控制。在终端上输入Ctrl+C、Ctrl+Z,则以该终端为控制终端的前台进程组会收到终止、暂停的信号。 - 对终端设备进行ioctl操作,可以实现终端相关的硬件参数设置。login、sudo的不显示密码,都离不开对终端设备的操作。 终端还可以指代“终端模拟器”。终端模拟器是应用程序,用于模拟一个终端。它一般是GUI程序,带有窗口。从窗口输入的字符作为模拟键盘的输入,在窗口上打印的字符作为模拟显示器的输出。终端模拟器还需要创建模拟的终端设备(如`/dev/pts/1`),用于当做命令行进程(CLI进程)的输入输出、控制终端。当键盘键入一个字符,它要让CLI进程从终端设备中读到这个字符,当CLI进程写入终端设备时,终端模拟器要读到并显示出来。 终端模拟器的这个需求,恰恰和telnet这种远程登录服务器的需求相似。telnet服务器也要创建模拟的终端设备,用于当做命令行进程(CLI进程)的输入输出、控制终端。当从网络收到一个字符,它要让CLI进程从终端设备中读到这个字符,当CLI进程写入终端设备时,telnet要把输出发送到网络。 这种共同的需求在linux中有一个统一实现——伪终端(pseudoterminal)。没错,上面的`/dev/pts/`文件夹里的以数字命名的文件就是伪终端的设备文件。 ### 伪终端的介绍 通过`man pts`可以查阅linux对伪终端的介绍。伪终端是伪终端master和伪终端slave(终端设备文件)这一对字符设备。`/dev/ptmx`是用于创建一对master、slave的文件。当一个进程打开它时,获得了一个master的文件描述符(file descriptor),同时在`/dev/pts`下创建了一个slave设备文件。 master端是更接近用户显示器、键盘的一端,slave端是在虚拟终端上运行的CLI(Command Line Interface,命令行接口)程序。Linux的伪终端驱动程序,会把“master端(如键盘)写入的数据”转发给slave端供程序输入,把“程序写入slave端的数据”转发给master端供(显示器驱动等)读取。 ![](https://source.mkshell.com/15324413302349.jpg) 我们打开的“终端”桌面程序,其实是一种终端模拟器。当终端模拟器运行时,它通过`/dev/ptmx`打开master端,创建了一个伪终端对,并让shell运行在slave端。当用户在终端模拟器中按下键盘按键时,它产生字节流并写入master中,shell便可从slave中读取输入;shell和它的子程序,将输出内容写入slave中,由终端模拟器负责将字符打印到窗口中。 (终端模拟器的显示原理就不在这里展开了,这里认为键盘按键形成一列字节流、向显示器输出字节流后便打印到屏幕上) linux中为什么要提出伪终端这个概念呢?shell等命令行程序不可以直接从显示器和键盘读取数据吗?为了同屏运行多个终端模拟器、并实现远程登录,还真不能让bash直接跨过伪终端这一层。在操作系统的一大思想——虚拟化的指导下,为多个终端模拟器、远程用户分配多个虚拟的终端是有必要的。上图中的shell使用的slave端就是一个虚拟化的终端。master端是模拟用户一端的交互。之所以称为虚拟化的终端,它除了转发数据流外,还要有点终端的样子。 ### 作为终端的伪终端 最为一个虚拟的终端,每一个伪终端里面封装了一个终端驱动,让它能做到这些事情: - 为程序提供一些输入输出模式的帮助,比如输入密码时隐藏字符 - 为用户提供对进程的控制,比如按下Ctrl+C结束前台进程 对,这些就是转发数据之外的控制。 #### 终端的属性:回显控制和行控制 当用户按下一个按键时,字符会出现在屏幕上。这可不是CLI进程写回来的。不信的话可以在终端里运行`cat`,随便输入些什么按回车。第二行是`cat`返回来的,第一行正是终端的特性。 终端驱动里存储了一个状态——回显控制:是否将写入master的字符再次送回master的读端(显示器)。默认情况下这个是启用的。在命令行里可以使用`stty`来更改终端的状态。比如在终端中运行 ``` stty -echo ``` 则会关掉当前终端的回显。这时按下按键,已经没有字符显示出来了。输入`ls`等命令,能够看到shell正常接收到我们的命令(此时回车并没有显示出来)。这时`cat`后,盲打一些文字,按下回车后看到只有一条文字了。 ![](https://source.mkshell.com/15324414282276.jpg) 除了用户通过命令行方式,CLI的程序还能通过系统调用来设置终端的回显,比如`login`,`sudo`等程序就是通过暂时关闭回显来隐藏密码的。具体方式是在slave的文件描述符上调用`ioctl`函数(参考`man tty_ioctl`),不过推荐使用更友好的`tcsetattr`函数。详细设置可查阅`man tcsetattr`。 另外,终端驱动还提供有行缓冲功能。还是以`cat`为例:当我们输入文字,在键入回车之前,`cat`并不能读取到我们输入的字符。这里的`cat`的行为可以理解为逐字符读写: ``` while(read(0, &c, 1) > 0) //read from stdin, while not EOF write(1, &c, 1); //write to stdout ``` 是谁阻止`cat`及时读入字符了呢?其实是终端驱动。它默认开启了一个行缓冲区,这样等程序要调用`read`系统调用时,先让程序阻塞着(blocked),等用户输入一整行后,才解除阻塞。我们可以使用下列命令将行缓存大小设置为1: ``` stty min 1 -icanon ``` 这时,运行`cat`,尝试输入文字。每输入一个字符,能够立即返回一个字符。(把`min`改为`time`,还能设置输入字符最长被阻塞1秒) 这些终端的状态属性信息还有很多,比如设置终端的宽度、高度等。具体可以参考`man stty`。 #### 特殊控制字符 特殊控制字符,是指Ctrl和其他键的组合。如Ctrl+C、Ctrl+Z等等。用户按下这些按键,终端模拟器(键盘)会在master端写入一个字节。规则是:Ctrl+字母得到的字节是(大写)字母的ascii码减去0x40。比如Ctrl+C是0x03,Ctrl+Z是0x1A。参见下表: ![](https://source.mkshell.com/15324415385595.jpg) 驱动收到这些特殊字符,并不会像收到正常字节那样处理。在echo的时候,它返回两个可见字符。比如键入Ctrl+C(0x03),就会回显^和C(0x5E 0x03)两个字符。更重要的是,驱动将会拦截某些控制字符,他们不会被转发给slave端,而是触发作业控制(job control)的规则:向前台进程组发送SIGINT信号。 要想绕过这一机制,我们可以使用stty的一些设置。下面的命令能够同时关闭控制字符的特殊语义、设置行缓冲大小为1: ``` stty raw ``` 然后,运行`cat`命令,我们键入的所有字符,包括控制字符Ctrl+C(0x03),都会成功传递给`cat`,并且被原样返回。(可以试试上下左右、回车键的效果) ## 结语 linux中终端驱动本身有回显、行缓存、作业控制等丰富的属性,在此基础上实现的伪终端在终端模拟器、远程登录等场合下能够得到多种应用。 在实验过程中也牵扯到进程控制、输入输出重定向、网络通信这么多的知识,更体现出linux的复杂精致的结构。我感觉,linux 就像一个包罗万象、又自成体统的小宇宙,它采用独特的虚拟化技术,灵活的模块化和重用机制,虚拟出各种设备,实现了驱动程序的随意拼插。在这里,所有模块都得到了充分的利用,并能够像变形金刚那样对各类需求提出面面俱到的解决方案。 原文链接:[https://www.cnblogs.com/zzdyyy/p/7538077.html](https://www.cnblogs.com/zzdyyy/p/7538077.html) https://www.mkshell.com/posts/2018-07-13-diff-path-library_path-ld_library_path-linux/ https://www.mkshell.com/posts/2018-07-13-diff-path-library_path-ld_library_path-linux.md 2018-07-13 # Linux中PATH、 LIBRARY_PATH、 LD_LIBRARY_PATH的区别 **_接触Linux或类Unix的系统多了,就会逃不出环境配置,而各种路径(PATH)就是环境的配置中较为重要的一点_** ## PATH PATH是可执行文件路径,是三个中我们最常接触到的,因为我们命令行中的每句能运行的命令,如ls、top、ps等,都是系统通过PATH找到了这个命令执行文件的所在位置,再run这个命令(可执行文件)。 **实验:**执行`echo $PATH`能看到当前环境的PATH都配置哪些路径 ![](https://source.mkshell.com/15314755943067.jpg) 像我的环境,我还添加了常用的,如pyenv、go、mysql等。 PATH配置起来也略简单。 比如说,在用户的目录下有一个bin文件夹,里面放了有可执行的二进制文件、shell脚本等。 **需求:**我想在任意目录下都能运行上述bin文件夹的可执行文件。 **操作:**那么我只需要把这个bin的路径添加到PATH上: ``` # vim ~/.bash_profile PATH=$PATH:~/bin ``` 即可。 ## LIBRARY_PATH和LD_LIBRARY_PATH 这两个路径可以放在一起讨论, - LIBRARY_PATH是程序**编译期间**查找动态链接库时指定查找共享库的路径 - LD_LIBRARY_PATH是程序**加载运行期间**查找动态链接库时指定除了系统默认路径之外的其他路径 两者的共同点是**库**,库是这两个路径和PATH路径的区别,PATH是可执行文件。 两者的差异点是使用时间不一样。一个是编译期,对应的是开发阶段,如gcc编译;一个是加载运行期,对应的是程序已交付的使用阶段。 配置方法也是类似: ``` export LD_LIBRARY_PATH=LD_LIBRARY_PATH:XXXX ``` _先这样吧_ 若有错误之处请指出,更多地关注[煎鱼](http://www.jianyujianyu.com)。 https://www.mkshell.com/posts/2018-07-13-tomcat-8-startup-slow-reason/ https://www.mkshell.com/posts/2018-07-13-tomcat-8-startup-slow-reason.md 2018-07-13 # Tomcat 8启动慢的原因 —— 熵池阻塞变慢详解 Tomcat 8启动很慢,且日志上无任何错误,在日志中查看到如下信息: ``` Log4j:[2015-10-29 15:47:11] INFO ReadProperty:172 - Loading properties file from class path resource [resources/jdbc.properties] Log4j:[2015-10-29 15:47:11] INFO ReadProperty:172 - Loading properties file from class path resource [resources/common.properties] 29-Oct-2015 15:52:53.587 INFO [localhost-startStop-1] org.apache.catalina.util.SessionIdGeneratorBase.createSecureRandom Creation of SecureRandom instance for session ID generation using [SHA1PRNG] took [342,445] milliseconds. ``` ## 原因 Tomcat 7/8都使用`org.apache.catalina.util.SessionIdGeneratorBase.createSecureRandom`类产生安全随机类SecureRandom的实例作为会话ID,这里花去了342秒,也即接近6分钟。 SHA1PRNG算法是基于SHA-1算法实现且保密性较强的伪随机数生成器。 在SHA1PRNG中,有一个种子产生器,它根据配置执行各种操作。 1. 如果java.security.egd属性或securerandom.source属性指定的是`file:/dev/random`或`file:/dev/urandom`,那么JVM会使用本地种子产生器NativeSeedGenerator,它会调用super()方法,即调用`SeedGenerator.URLSeedGenerator(/dev/random)`方法进行初始化。 2. 如果java.security.egd属性或securerandom.source属性指定的是其它已存在的URL,那么会调用`SeedGenerator.URLSeedGenerator(url)`方法进行初始化。 这就是为什么我们设置值为`file:///dev/urandom`或者值为`file:/./dev/random`都会起作用的原因。 在这个实现中,产生器会评估熵池(entropy pool)中的噪声数量。随机数是从熵池中进行创建的。当读操作时,/dev/random设备会只返回熵池中噪声的随机字节。/dev/random非常适合那些需要非常高质量随机性的场景,比如一次性的支付或生成密钥的场景。 当熵池为空时,来自/dev/random的读操作将被阻塞,直到熵池收集到足够的环境噪声数据。这么做的目的是成为一个密码安全的伪随机数发生器,熵池要有尽可能大的输出。对于生成高质量的加密密钥或者是需要长期保护的场景,一定要这么做。 那么什么是环境噪声? 随机数产生器会手机来自设备驱动器和其它源的环境噪声数据,并放入熵池中。产生器会评估熵池中的噪声数据的数量。当熵池为空时,这个噪声数据的收集是比较花时间的。这就意味着,Tomcat在生产环境中使用熵池时,会被阻塞较长的时间。 ## 解决 有两种解决办法: 1. 在Tomcat环境中解决 可以通过配置JRE使用非阻塞的Entropy Source。 在catalina.sh中加入这么一行:`-Djava.security.egd=file:/dev/./urandom` 即可。 加入后再启动Tomcat,整个启动耗时下降到Server startup in 2912 ms。 2. 在JVM环境中解决 打开`$JAVA_PATH/jre/lib/security/java.security`这个文件,找到下面的内容: ``` securerandom.source=file:/dev/urandom ``` 替换成 ``` securerandom.source=file:/dev/./urandom ``` 原文链接:[https://blog.csdn.net/chszs/article/details/49494701](https://blog.csdn.net/chszs/article/details/49494701 'https://blog.csdn.net/chszs/article/details/49494701') https://www.mkshell.com/posts/2018-07-13-visit-not-exist-website/ https://www.mkshell.com/posts/2018-07-13-visit-not-exist-website.md 2018-07-13 # 访问一个不存在的网站是发生什么 # 访问一个不存在的网站是发生什么 **_所在的群里阿翰同学突然提了个很有意思的问题:访问一个不存在的网站会返回什么状态码,于是乎_** ![1531480417841](https://source.mkshell.com/1531480417841.jpg) 虽然不是问我,但是我稍微想了一下,这个问题还是挺简单的。 状态码是http response的一部分,所以网站[http://www.bucunzaidewangzhan.com/](https://www.jianyujianyu.com)不存在造成没有response,那么状态码也自然而然地不存在。 有些同学可能会疑惑,就会说“不存在不是[404](https://www.jianyujianyu.com)吗”。 然而这个404是是网站可访问(存在HTTP服务器)的情况下,某个页面不存在,因此和这种情况不是同一种情况。 那么,既然没有状态码,接下来的问题大多数程序员也该想到了 —— 怎么去识别它不存在呢? ![1531480459408](https://source.mkshell.com/1531480459408.jpg) 是的,阿翰同学的下一个问题就是这样。 煎鱼接到问题就立刻用POSTMAN试了一下, ![32132112321](https://source.mkshell.com/32132112321.png) POSTMAN给的信息是没有[response](https://www.jianyujianyu.com),这个的的确确是符合预期的。 而CURL的呢 ![](https://source.mkshell.com/15314810025650.jpg) CURL给的是[could not resolve host](https://www.jianyujianyu.com) 于是,我猜,要按实际情况区分,代码测试到的是什么就是什么。 群里有同学立刻pop出了他的Python测试结果: ![WechatIMG3444](https://source.mkshell.com/WechatIMG3444.png) 用的requests模块,抛的是[ConnectionError('Connect aborted')](https://www.jianyujianyu.com)。 我也用Java测试了一下: ![3213214q4214](https://source.mkshell.com/3213214q4214.png) 抛的是UnknownHostException。 还是得测试一下才知道是什么错误吧- -。 _先这样吧_ 若有错误之处请指出,更多地关注[煎鱼](http://www.jianyujianyu.com)。 https://www.mkshell.com/posts/2018-06-23-learning-matrix-1/ https://www.mkshell.com/posts/2018-06-23-learning-matrix-1.md 2018-06-23 # 理解矩阵(一) 前不久chensh出于不可告人的目的,要充当老师,教别人线性代数。于是我被揪住就线性代数中一些务虚性的问题与他讨论了几次。很明显,chensh觉得,要让自己在讲线性代数的时候不被那位强势的学生认为是神经病,还是比较难的事情。 可怜的chensh,谁让你趟这个地雷阵?!色令智昏啊! 线性代数课程,无论你从行列式入手还是直接从矩阵入手,从一开始就充斥着莫名其妙。比如说,在全国一般工科院系教学中应用最广泛的同济线性代数教材(现在到了第四版),一上来就介绍逆序数这个“前无古人,后无来者”的古怪概念,然后用逆序数给出行列式的一个极不直观的定义,接着是一些简直犯傻的行列式性质和习题——把这行乘一个系数加到另一行上,再把那一列减过来,折腾得那叫一个热闹,可就是压根看不出这个东西有嘛用。大多数像我一样资质平庸的学生到这里就有点犯晕:连这是个什么东西都模模糊糊的,就开始钻火圈表演了,这未免太“无厘头”了吧!于是开始有人逃课,更多的人开始抄作业。这下就中招了,因为其后的发展可以用一句峰回路转来形容,紧跟着这个无厘头的行列式的,是一个同样无厘头但是伟大的无以复加的家伙的出场——矩阵来了!多年之后,我才明白,当老师犯傻似地用中括号把一堆傻了吧叽的数括起来,并且不紧不慢地说:“这个东西叫做矩阵”的时候,我的数学生涯掀开了何等悲壮辛酸、惨绝人寰的一幕!自那以后,在几乎所有跟“学问”二字稍微沾点边的东西里,矩阵这个家伙从不缺席。对于我这个没能一次搞定线性代数的笨蛋来说,矩阵老大的不请自来每每搞得我灰头土脸,头破血流。长期以来,我在阅读中一见矩阵,就如同阿Q见到了假洋鬼子,揉揉额角就绕道走。 事实上,我并不是特例。一般工科学生初学线性代数,通常都会感到困难。这种情形在国内外皆然。瑞典数学家Lars Garding在其名著Encounter with Mathematics中说:“如果不熟悉线性代数的概念,要去学习自然科学,现在看来就和文盲差不多。”,然而“按照现行的国际标准,线性代数是通过公理化来表述的,它是第二代数学模型,...,这就带来了教学上的困难。”事实上,当我们开始学习线性代数的时候,不知不觉就进入了“第二代数学模型”的范畴当中,这意味着数学的表述方式和抽象性有了一次全面的进化,对于从小一直在“第一代数学模型”,即以实用为导向的、具体的数学模型中学习的我们来说,在没有并明确告知的情况下进行如此剧烈的paradigm shift,不感到困难才是奇怪的。 大部分工科学生,往往是在学习了一些后继课程,如数值分析、数学规划、矩阵论之后,才逐渐能够理解和熟练运用线性代数。即便如此,不少人即使能够很熟练地以线性代数为工具进行科研和应用工作,但对于很多这门课程的初学者提出的、看上去是很基础的问题却并不清楚。比如说: - 矩阵究竟是什么东西?向量可以被认为是具有n个相互独立的性质(维度)的对象的表示,矩阵又是什么呢?我们如果认为矩阵是一组列(行)向量组成的新的复合向量的展开式,那么为什么这种展开式具有如此广泛的应用?特别是,为什么偏偏二维的展开式如此有用?如果矩阵中每一个元素又是一个向量,那么我们再展开一次,变成三维的立方阵,是不是更有用? - 矩阵的乘法规则究竟为什么这样规定?为什么这样一种怪异的乘法规则却能够在实践中发挥如此巨大的功效?很多看上去似乎是完全不相关的问题,最后竟然都归结到矩阵的乘法,这难道不是很奇妙的事情?难道在矩阵乘法那看上去莫名其妙的规则下面,包含着世界的某些本质规律?如果是的话,这些本质规律是什么? - 行列式究竟是一个什么东西?为什么会有如此怪异的计算规则?行列式与其对应方阵本质上是什么关系?为什么只有方阵才有对应的行列式,而一般矩阵就没有(不要觉得这个问题很蠢,如果必要,针对m x n矩阵定义行列式不是做不到的,之所以不做,是因为没有这个必要,但是为什么没有这个必要)?而且,行列式的计算规则,看上去跟矩阵的任何计算规则都没有直观的联系,为什么又在很多方面决定了矩阵的性质?难道这一切仅是巧合? - 矩阵为什么可以分块计算?分块计算这件事情看上去是那么随意,为什么竟是可行的? - 对于矩阵转置运算AT,有(AB)T = BTAT,对于矩阵求逆运算A-1,有(AB)-1 = B-1A-1。两个看上去完全没有什么关系的运算,为什么有着类似的性质?这仅仅是巧合吗? - 为什么说P-1AP得到的矩阵与A矩阵“相似”?这里的“相似”是什么意思? - 特征值和特征向量的本质是什么?它们定义就让人很惊讶,因为Ax =λx,一个诺大的矩阵的效应,竟然不过相当于一个小小的数λ,确实有点奇妙。但何至于用“特征”甚至“本征”来界定?它们刻划的究竟是什么? 这样的一类问题,经常让使用线性代数已经很多年的人都感到为难。就好像大人面对小孩子的刨根问底,最后总会迫不得已地说“就这样吧,到此为止”一样,面对这样的问题,很多老手们最后也只能用:“就是这么规定的,你接受并且记住就好”来搪塞。然而,这样的问题如果不能获得回答,线性代数对于我们来说就是一个粗暴的、不讲道理的、莫名其妙的规则集合,我们会感到,自己并不是在学习一门学问,而是被不由分说地“抛到”一个强制的世界中,只是在考试的皮鞭挥舞之下被迫赶路,全然无法领略其中的美妙、和谐与统一。直到多年以后,我们已经发觉这门学问如此的有用,却仍然会非常迷惑:怎么这么凑巧? 我认为,这是我们的线性代数教学中直觉性丧失的后果。上述这些涉及到“如何能”、“怎么会”的问题,仅仅通过纯粹的数学证明来回答,是不能令提问者满意的。比如,如果你通过一般的证明方法论证了矩阵分块运算确实可行,那么这并不能够让提问者的疑惑得到解决。他们真正的困惑是:矩阵分块运算为什么竟然是可行的?究竟只是凑巧,还是说这是由矩阵这种对象的某种本质所必然决定的?如果是后者,那么矩阵的这些本质是什么?只要对上述那些问题稍加考虑,我们就会发现,所有这些问题都不是单纯依靠数学证明所能够解决的。像我们的教科书那样,凡事用数学证明,最后培养出来的学生,只能熟练地使用工具,却欠缺真正意义上的理解。 自从1930年代法国布尔巴基学派兴起以来,数学的公理化、系统性描述已经获得巨大的成功,这使得我们接受的数学教育在严谨性上大大提高。然而数学公理化的一个备受争议的副作用,就是一般数学教育中直觉性的丧失。数学家们似乎认为直觉性与抽象性是矛盾的,因此毫不犹豫地牺牲掉前者。然而包括我本人在内的很多人都对此表示怀疑,我们不认为直觉性与抽象性一定相互矛盾,特别是在数学教育中和数学教材中,帮助学生建立直觉,有助于它们理解那些抽象的概念,进而理解数学的本质。反之,如果一味注重形式上的严格性,学生就好像被迫进行钻火圈表演的小白鼠一样,变成枯燥的规则的奴隶。 对于线性代数的类似上述所提到的一些直觉性的问题,两年多来我断断续续地反复思考了四、五次,为此阅读了好几本国内外线性代数、数值分析、代数和数学通论性书籍,其中像前苏联的名著《数学:它的内容、方法和意义》、龚昇教授的《线性代数五讲》、前面提到的Encounter with Mathematics(《数学概观》)以及Thomas A. Garrity的《数学拾遗》都给我很大的启发。不过即使如此,我对这个主题的认识也经历了好几次自我否定。比如以前思考的一些结论曾经写在自己的blog里,但是现在看来,这些结论基本上都是错误的。因此打算把自己现在的有关理解比较完整地记录下来,一方面是因为我觉得现在的理解比较成熟了,可以拿出来与别人探讨,向别人请教。另一方面,如果以后再有进一步的认识,把现在的理解给推翻了,那现在写的这个snapshot也是很有意义的。 因为打算写得比较多,所以会分几次慢慢写。也不知道是不是有时间慢慢写完整,会不会中断,写着看吧。 --- 今天先谈谈对线形空间和矩阵的几个核心概念的理解。这些东西大部分是凭着自己的理解写出来的,基本上不抄书,可能有错误的地方,希望能够被指出。但我希望做到直觉,也就是说能把数学背后说的实质问题说出来。 首先说说空间(space),这个概念是现代数学的命根子之一,从拓扑空间开始,一步步往上加定义,可以形成很多空间。线形空间其实还是比较初级的,如果在里面定义了范数,就成了赋范线性空间。赋范线性空间满足完备性,就成了巴那赫空间;赋范线性空间中定义角度,就有了内积空间,内积空间再满足完备性,就得到希尔伯特空间。 总之,空间有很多种。你要是去看某种空间的数学定义,大致都是“存在一个集合,在这个集合上定义某某概念,然后满足某些性质”,就可以被称为空间。这未免有点奇怪,为什么要用“空间”来称呼一些这样的集合呢?大家将会看到,其实这是很有道理的。 我们一般人最熟悉的空间,毫无疑问就是我们生活在其中的(按照牛顿的绝对时空观)的三维空间,从数学上说,这是一个三维的欧几里德空间,我们先不管那么多,先看看我们熟悉的这样一个空间有些什么最基本的特点。仔细想想我们就会知道,这个三维的空间:1. 由很多(实际上是无穷多个)位置点组成;2. 这些点之间存在相对的关系;3. 可以在空间中定义长度、角度;4. 这个空间可以容纳运动,这里我们所说的运动是从一个点到另一个点的移动(变换),而不是微积分意义上的“连续”性的运动, 上面的这些性质中,最最关键的是第4条。第1、2条只能说是空间的基础,不算是空间特有的性质,凡是讨论数学问题,都得有一个集合,大多数还得在这个集合上定义一些结构(关系),并不是说有了这些就算是空间。而第3条太特殊,其他的空间不需要具备,更不是关键的性质。只有第4条是空间的本质,也就是说,容纳运动是空间的本质特征。 认识到了这些,我们就可以把我们关于三维空间的认识扩展到其他的空间。事实上,不管是什么空间,都必须容纳和支持在其中发生的符合规则的运动(变换)。你会发现,在某种空间中往往会存在一种相对应的变换,比如拓扑空间中有拓扑变换,线性空间中有线性变换,仿射空间中有仿射变换,其实这些变换都只不过是对应空间中允许的运动形式而已。 因此只要知道,“空间”是容纳运动的一个对象集合,而变换则规定了对应空间的运动。 下面我们来看看线性空间。线性空间的定义任何一本书上都有,但是既然我们承认线性空间是个空间,那么有两个最基本的问题必须首先得到解决,那就是: 1. 空间是一个对象集合,线性空间也是空间,所以也是一个对象集合。那么线性空间是什么样的对象的集合?或者说,线性空间中的对象有什么共同点吗? 2. 线性空间中的运动如何表述的?也就是,线性变换是如何表示的? 我们先来回答第一个问题,回答这个问题的时候其实是不用拐弯抹角的,可以直截了当的给出答案。线性空间中的任何一个对象,通过选取基和坐标的办法,都可以表达为向量的形式。通常的向量空间我就不说了,举两个不那么平凡的例子: L1. 最高次项不大于n次的多项式的全体构成一个线性空间,也就是说,这个线性空间中的每一个对象是一个多项式。如果我们以x0, x1, ..., xn为基,那么任何一个这样的多项式都可以表达为一组n+1维向量,其中的每一个分量ai其实就是多项式中x(i-1)项的系数。值得说明的是,基的选取有多种办法,只要所选取的那一组基线性无关就可以。这要用到后面提到的概念了,所以这里先不说,提一下而已。 L2. 闭区间\[a, b\]上的n阶连续可微函数的全体,构成一个线性空间。也就是说,这个线性空间的每一个对象是一个连续函数。对于其中任何一个连续函数,根据魏尔斯特拉斯定理,一定可以找到最高次项不大于n的多项式函数,使之与该连续函数的差为0,也就是说,完全相等。这样就把问题归结为L1了。后面就不用再重复了。 所以说,向量是很厉害的,只要你找到合适的基,用向量可以表示线性空间里任何一个对象。这里头大有文章,因为向量表面上只是一列数,但是其实由于它的有序性,所以除了这些数本身携带的信息之外,还可以在每个数的对应位置上携带信息。为什么在程序设计中数组最简单,却又威力无穷呢?根本原因就在于此。这是另一个问题了,这里就不说了。 下面来回答第二个问题,这个问题的回答会涉及到线性代数的一个最根本的问题。 线性空间中的运动,被称为线性变换。也就是说,你从线性空间中的一个点运动到任意的另外一个点,都可以通过一个线性变化来完成。那么,线性变换如何表示呢?很有意思,在线性空间中,当你选定一组基之后,不仅可以用一个向量来描述空间中的任何一个对象,而且可以用矩阵来描述该空间中的任何一个运动(变换)。而使某个对象发生对应运动的方法,就是用代表那个运动的矩阵,乘以代表那个对象的向量。 简而言之,在线性空间中选定基之后,向量刻画对象,矩阵刻画对象的运动,用矩阵与向量的乘法施加运动。 是的,矩阵的本质是运动的描述。如果以后有人问你矩阵是什么,那么你就可以响亮地告诉他,矩阵的本质是运动的描述。(chensh,说你呢!) 可是多么有意思啊,向量本身不是也可以看成是n x 1矩阵吗?这实在是很奇妙,一个空间中的对象和运动竟然可以用相类同的方式表示。能说这是巧合吗?如果是巧合的话,那可真是幸运的巧合!可以说,线性代数中大多数奇妙的性质,均与这个巧合有直接的关系。 原文链接:[https://blog.csdn.net/myan/article/details/647511](https://blog.csdn.net/myan/article/details/647511 'https://blog.csdn.net/myan/article/details/647511')。 https://www.mkshell.com/posts/2018-06-23-learning-matrix-2/ https://www.mkshell.com/posts/2018-06-23-learning-matrix-2.md 2018-06-23 # 理解矩阵(二) 接着理解矩阵。 上一篇里说“矩阵是运动的描述”,到现在为止,好像大家都还没什么意见。但是我相信早晚会有数学系出身的网友来拍板转。因为运动这个概念,在数学和物理里是跟微积分联系在一起的。我们学习微积分的时候,总会有人照本宣科地告诉你,初等数学是研究常量的数学,是研究静态的数学,高等数学是变量的数学,是研究运动的数学。大家口口相传,差不多人人都知道这句话。但是真知道这句话说的是什么意思的人,好像也不多。简而言之,在我们人类的经验里,运动是一个连续过程,从A点到B点,就算走得最快的光,也是需要一个时间来逐点地经过AB之间的路径,这就带来了连续性的概念。而连续这个事情,如果不定义极限的概念,根本就解释不了。古希腊人的数学非常强,但就是缺乏极限观念,所以解释不了运动,被芝诺的那些著名悖论(飞箭不动、飞毛腿阿喀琉斯跑不过乌龟等四个悖论)搞得死去活来。因为这篇文章不是讲微积分的,所以我就不多说了。有兴趣的读者可以去看看齐民友教授写的《重温微积分》。我就是读了这本书开头的部分,才明白“高等数学是研究运动的数学”这句话的道理。 不过在我这个《理解矩阵》的文章里,“运动”的概念不是微积分中的连续性的运动,而是瞬间发生的变化。比如这个时刻在A点,经过一个“运动”,一下子就“跃迁”到了B点,其中不需要经过A点与B点之间的任何一个点。这样的“运动”,或者说“跃迁”,是违反我们日常的经验的。不过了解一点量子物理常识的人,就会立刻指出,量子(例如电子)在不同的能量级轨道上跳跃,就是瞬间发生的,具有这样一种跃迁行为。所以说,自然界中并不是没有这种运动现象,只不过宏观上我们观察不到。但是不管怎么说,“运动”这个词用在这里,还是容易产生歧义的,说得更确切些,应该是“跃迁”。因此这句话可以改成: “矩阵是线性空间里跃迁的描述”。 可是这样说又太物理,也就是说太具体,而不够数学,也就是说不够抽象。因此我们最后换用一个正牌的数学术语——变换,来描述这个事情。这样一说,大家就应该明白了,所谓变换,其实就是空间里从一个点(元素/对象)到另一个点(元素/对象)的跃迁。比如说,拓扑变换,就是在拓扑空间里从一个点到另一个点的跃迁。再比如说,仿射变换,就是在仿射空间里从一个点到另一个点的跃迁。附带说一下,这个仿射空间跟向量空间是亲兄弟。做计算机图形学的朋友都知道,尽管描述一个三维对象只需要三维向量,但所有的计算机图形学变换矩阵都是4 x 4的。说其原因,很多书上都写着“为了使用中方便”,这在我看来简直就是企图蒙混过关。真正的原因,是因为在计算机图形学里应用的图形变换,实际上是在仿射空间而不是向量空间中进行的。想想看,在向量空间里相一个向量平行移动以后仍是相同的那个向量,而现实世界等长的两个平行线段当然不能被认为同一个东西,所以计算机图形学的生存空间实际上是仿射空间。而仿射变换的矩阵表示根本就是4 x 4的。又扯远了,有兴趣的读者可以去看《计算机图形学——几何工具算法详解》。 一旦我们理解了“变换”这个概念,矩阵的定义就变成: “矩阵是线性空间里的变换的描述。” 到这里为止,我们终于得到了一个看上去比较数学的定义。不过还要多说几句。教材上一般是这么说的,在一个线性空间V里的一个线性变换T,当选定一组基之后,就可以表示为矩阵。因此我们还要说清楚到底什么是线性变换,什么是基,什么叫选定一组基。线性变换的定义是很简单的,设有一种变换T,使得对于线性空间V中间任何两个不相同的对象x和y,以及任意实数a和b,有: T(ax + by) = aT(x) + bT(y), 那么就称T为线性变换。 定义都是这么写的,但是光看定义还得不到直觉的理解。线性变换究竟是一种什么样的变换?我们刚才说了,变换是从空间的一个点跃迁到另一个点,而线性变换,就是从一个线性空间V的某一个点跃迁到另一个线性空间W的另一个点的运动。这句话里蕴含着一层意思,就是说一个点不仅可以变换到同一个线性空间中的另一个点,而且可以变换到另一个线性空间中的另一个点去。不管你怎么变,只要变换前后都是线性空间中的对象,这个变换就一定是线性变换,也就一定可以用一个非奇异矩阵来描述。而你用一个非奇异矩阵去描述的一个变换,一定是一个线性变换。有的人可能要问,这里为什么要强调非奇异矩阵?所谓非奇异,只对方阵有意义,那么非方阵的情况怎么样?这个说起来就会比较冗长了,最后要把线性变换作为一种映射,并且讨论其映射性质,以及线性变换的核与像等概念才能彻底讲清楚。我觉得这个不算是重点,如果确实有时间的话,以后写一点。以下我们只探讨最常用、最有用的一种变换,就是在同一个线性空间之内的线性变换。也就是说,下面所说的矩阵,不作说明的话,就是方阵,而且是非奇异方阵。学习一门学问,最重要的是把握主干内容,迅速建立对于这门学问的整体概念,不必一开始就考虑所有的细枝末节和特殊情况,自乱阵脚。 接着往下说,什么是基呢?这个问题在后面还要大讲一番,这里只要把基看成是线性空间里的坐标系就可以了。注意是坐标系,不是坐标值,这两者可是一个“对立矛盾统一体”。这样一来,“选定一组基”就是说在线性空间里选定一个坐标系。就这意思。 好,最后我们把矩阵的定义完善如下: “矩阵是线性空间中的线性变换的一个描述。在一个线性空间中,只要我们选定一组基,那么对于任何一个线性变换,都能够用一个确定的矩阵来加以描述。” 理解这句话的关键,在于把“线性变换”与“线性变换的一个描述”区别开。一个是那个对象,一个是对那个对象的表述。就好像我们熟悉的面向对象编程中,一个对象可以有多个引用,每个引用可以叫不同的名字,但都是指的同一个对象。如果还不形象,那就干脆来个很俗的类比。 比如有一头猪,你打算给它拍照片,只要你给照相机选定了一个镜头位置,那么就可以给这头猪拍一张照片。这个照片可以看成是这头猪的一个描述,但只是一个片面的的描述,因为换一个镜头位置给这头猪拍照,能得到一张不同的照片,也是这头猪的另一个片面的描述。所有这样照出来的照片都是这同一头猪的描述,但是又都不是这头猪本身。 同样的,对于一个线性变换,只要你选定一组基,那么就可以找到一个矩阵来描述这个线性变换。换一组基,就得到一个不同的矩阵。所有这些矩阵都是这同一个线性变换的描述,但又都不是线性变换本身。 但是这样的话,问题就来了如果你给我两张猪的照片,我怎么知道这两张照片上的是同一头猪呢?同样的,你给我两个矩阵,我怎么知道这两个矩阵是描述的同一个线性变换呢?如果是同一个线性变换的不同的矩阵描述,那就是本家兄弟了,见面不认识,岂不成了笑话。 好在,我们可以找到同一个线性变换的矩阵兄弟们的一个性质,那就是: 若矩阵A与B是同一个线性变换的两个不同的描述(之所以会不同,是因为选定了不同的基,也就是选定了不同的坐标系),则一定能找到一个非奇异矩阵P,使得A、B之间满足这样的关系: A = P-1BP 线性代数稍微熟一点的读者一下就看出来,这就是相似矩阵的定义。没错,所谓相似矩阵,就是同一个线性变换的不同的描述矩阵。按照这个定义,同一头猪的不同角度的照片也可以成为相似照片。俗了一点,不过能让人明白。 而在上面式子里那个矩阵P,其实就是A矩阵所基于的基与B矩阵所基于的基这两组基之间的一个变换关系。关于这个结论,可以用一种非常直觉的方法来证明(而不是一般教科书上那种形式上的证明),如果有时间的话,我以后在blog里补充这个证明。 这个发现太重要了。原来一族相似矩阵都是同一个线性变换的描述啊!难怪这么重要!工科研究生课程中有矩阵论、矩阵分析等课程,其中讲了各种各样的相似变换,比如什么相似标准型,对角化之类的内容,都要求变换以后得到的那个矩阵与先前的那个矩阵式相似的,为什么这么要求?因为只有这样要求,才能保证变换前后的两个矩阵是描述同一个线性变换的。当然,同一个线性变换的不同矩阵描述,从实际运算性质来看并不是不分好环的。有些描述矩阵就比其他的矩阵性质好得多。这很容易理解,同一头猪的照片也有美丑之分嘛。所以矩阵的相似变换可以把一个比较丑的矩阵变成一个比较美的矩阵,而保证这两个矩阵都是描述了同一个线性变换。 这样一来,矩阵作为线性变换描述的一面,基本上说清楚了。但是,事情没有那么简单,或者说,线性代数还有比这更奇妙的性质,那就是,矩阵不仅可以作为线性变换的描述,而且可以作为一组基的描述。而作为变换的矩阵,不但可以把线性空间中的一个点给变换到另一个点去,而且也能够把线性空间中的一个坐标系(基)表换到另一个坐标系(基)去。而且,变换点与变换坐标系,具有异曲同工的效果。线性代数里最有趣的奥妙,就蕴含在其中。理解了这些内容,线性代数里很多定理和规则会变得更加清晰、直觉。 这个留在下一篇再写吧。 因为有别的事情要做,下一篇可能要过几天再写了。 原文链接:[https://blog.csdn.net/myan/article/details/649018/](https://blog.csdn.net/myan/article/details/649018/ 'https://blog.csdn.net/myan/article/details/649018/') https://www.mkshell.com/posts/2018-06-23-learning-matrix-3/ https://www.mkshell.com/posts/2018-06-23-learning-matrix-3.md 2018-06-23 # 理解矩阵(三) 这两篇文章发表于去年的4月。在第二部分结束的时候,我说: > “矩阵不仅可以作为线性变换的描述,而且可以作为一组基的描述。而 作为变换的矩阵,不但可以把线性空间中的一个点给变换到另一个点去,而且也能够把线性空间中的一个坐标系(基)表换到另一个坐标系(基)去。而且,变换点 与变换坐标系,具有异曲同工的效果。线性代数里最有趣的奥妙,就蕴含在其中。理解了这些内容,线性代数里很多定理和规则会变得更加清晰、直觉。 这个留在下一篇再写吧。 因为有别的事情要做,下一篇可能要过几天再写了。 ” 然而这一拖就是一年半。一年半以来,这两篇粗糙放肆的文章被到处转载,以至于在Google的搜索提示中,我的名字跟“矩阵”是一对关联词汇。这对于学生时代数学一直很差的我来说,实在是令人惶恐的事情。数学是何等辉煌精致的学问!代表着人类智慧的最高成就,是人与上帝对话的语言。而我实在连数学的门都还没进去,不要说谈什么理解,就是稍微难一些的题目我也很少能解开。我有什么资格去谈矩阵这样重要的一个数学概念呢?更何况,我的想法直观是直观,未见的是正确的啊,会不会误人子弟呢?因此,算了吧,到此为止吧,我这么想。 是时不时收到的来信逐渐改变了我的想法。 一年半以来,我收到过不下一百封直接的来信,要求我把后面的部分写出来。这些来信大部分是国内的网友和学生,也有少数来自正在国外深造的朋友,大部分是鼓励,有的是诚挚的请求,也有少数严厉斥责我不守承诺。不管是何种态度,这都表明他们对我这一点点小小的思考成果的鼓励,特别是对于我这种思维的视角和尝试的鼓励。他们在信中让我知道,尽管我的数学水平不高,但是我这种从普通人(而不是数学家)视角出发,强调对数学概念和规则的直觉理解的思路,对于很多人是有益的。也许这条路子在数学中绝非正道,也不会走得很远,但是无论如何,在一定的阶段,对一部分人来说,较之目前数学教材普遍采用的思路,这种方式可能更容易理解一些。既然是可能对一部分人有帮助的事情,那么我就不应该心存太多杂念,应该不断思考和总结下去。 所以,下面就是你们来信要求我写出来的东西。 首先来总结一下前面两部分的一些主要结论: 1. 首先有空间,空间可以容纳对象运动的。一种空间对应一类对象。 2. 有一种空间叫线性空间,线性空间是容纳向量对象运动的。 3. 运动是瞬时的,因此也被称为变换。 4. 矩阵是线性空间中运动(变换)的描述。 5. 矩阵与向量相乘,就是实施运动(变换)的过程。 6. 同一个变换,在不同的坐标系下表现为不同的矩阵,但是它们的本质是一样的,所以本征值相同。 下面让我们把视力集中到一点以改变我们以往看待矩阵的方式。我们知道,线性空间里的基本对象是向量,而向量是这么表示的: `[a1, a2, a3, ..., an]` 矩阵呢?矩阵是这么表示的: ``` a11, a12, a13, ..., a1n a21, a22, a23, ..., a2n ... an1, an2, an3, ..., ann ``` 不用太聪明,我们就能看出来,矩阵是一组向量组成的。特别的,n维线性空间里的方阵是由n个n维向量组成的。我们在这里只讨论这个n阶的、非奇异的方阵,因为理解它就是理解矩阵的关键,它才是一般情况,而其他矩阵都是意外,都是不得不对付的讨厌状况,大可以放在一边。这里多一句嘴,学习东西要抓住主流,不要纠缠于旁支末节。很可惜我们的教材课本大多数都是把主线埋没在细节中的,搞得大家还没明白怎么回事就先被灌晕了。比如数学分析,明明最要紧的观念是说,一个对象可以表达为无穷多个合理选择的对象的线性和,这个概念是贯穿始终的,也是数学分析的精华。但是课本里自始至终不讲这句话,反正就是让你做吉米多维奇,掌握一大堆解偏题的技巧,记住各种特殊情况,两类间断点,怪异的可微和可积条件(谁还记得柯西条件、迪里赫莱条件...?),最后考试一过,一切忘光光。要我说,还不如反复强调这一个事情,把它深深刻在脑子里,别的东西忘了就忘了,真碰到问题了,再查数学手册嘛,何必因小失大呢? 言归正传。如果一组向量是彼此线性无关的话,那么它们就可以成为度量这个线性空间的一组基,从而事实上成为一个坐标系体系,其中每一个向量都躺在一根坐标轴上,并且成为那根坐标轴上的基本度量单位(长度1)。 现在到了关键的一步。看上去矩阵就是由一组向量组成的,而且如果矩阵非奇异的话(我说了,只考虑这种情况),那么组成这个矩阵的那一组向量也就是线性无关的了,也就可以成为度量线性空间的一个坐标系。结论:矩阵描述了一个坐标系。 “慢着!”,你嚷嚷起来了,“你这个骗子!你不是说过,矩阵就是运动吗?怎么这会矩阵又是坐标系了?” 嗯,所以我说到了关键的一步。我并没有骗人,之所以矩阵又是运动,又是坐标系,那是因为—— “运动等价于坐标系变换”。 对不起,这话其实不准确,我只是想让你印象深刻。准确的说法是: “对象的变换等价于坐标系的变换”。 或者: “固定坐标系下一个对象的变换等价于固定对象所处的坐标系变换。” 说白了就是: “运动是相对的。” 让我们想想,达成同一个变换的结果,比如把点(1, 1)变到点(2, 3)去,你可以有两种做法。第一,坐标系不动,点动,把(1, 1)点挪到(2, 3)去。第二,点不动,变坐标系,让x轴的度量(单位向量)变成原来的1/2,让y轴的度量(单位向量)变成原先的1/3,这样点还是那个点,可是点的坐标就变成(2, 3)了。方式不同,结果一样。 从第一个方式来看,那就是我在《理解矩阵》1/2中说的,把矩阵看成是运动描述,矩阵与向量相乘就是使向量(点)运动的过程。在这个方式下, Ma = b 的意思是: “向量a经过矩阵M所描述的变换,变成了向量b。” 而从第二个方式来看,矩阵M描述了一个坐标系,姑且也称之为M。那么: Ma = b 的意思是: “有一个向量,它在坐标系M的度量下得到的度量结果向量为a,那么它在坐标系I的度量下,这个向量的度量结果是b。” 这里的I是指单位矩阵,就是主对角线是1,其他为零的矩阵。 而这两个方式本质上是等价的。 我希望你务必理解这一点,因为这是本篇的关键。 正因为是关键,所以我得再解释一下。 在M为坐标系的意义下,如果把M放在一个向量a的前面,形成Ma的样式,我们可以认为这是对向量a的一个环境声明。它相当于是说: “注意了!这里有一个向量,它在坐标系M中度量,得到的度量结果可以表达为a。可是它在别的坐标系里度量的话,就会得到不同的结果。为了明确,我把M放在前面,让你明白,这是该向量在坐标系M中度量的结果。” 那么我们再看孤零零的向量b: b 多看几遍,你没看出来吗?它其实不是b,它是: Ib 也就是说:“在单位坐标系,也就是我们通常说的直角坐标系I中,有一个向量,度量的结果是b。” 而 Ma = Ib的意思就是说: “在M坐标系里量出来的向量a,跟在I坐标系里量出来的向量b,其实根本就是一个向量啊!” 这哪里是什么乘法计算,根本就是身份识别嘛。 从这个意义上我们重新理解一下向量。向量这个东西客观存在,但是要把它表示出来,就要把它放在一个坐标系中去度量它,然后把度量的结果(向量在各个坐标轴上的投影值)按一定顺序列在一起,就成了我们平时所见的向量表示形式。你选择的坐标系(基)不同,得出来的向量的表示就不同。向量还是那个向量,选择的坐标系不同,其表示方式就不同。因此,按道理来说,每写出一个向量的表示,都应该声明一下这个表示是在哪个坐标系中度量出来的。表示的方式,就是 Ma,也就是说,有一个向量,在M矩阵表示的坐标系中度量出来的结果为a。我们平时说一个向量是`[2 3 5 7]T`,隐含着是说,这个向量在 I 坐标系中的度量结果是`[2 3 5 7]T`,因此,这个形式反而是一种简化了的特殊情况。 注意到,M矩阵表示出来的那个坐标系,由一组基组成,而那组基也是由向量组成的,同样存在这组向量是在哪个坐标系下度量而成的问题。也就是说,表述一个矩阵的一般方法,也应该要指明其所处的基准坐标系。所谓M,其实是 IM,也就是说,M中那组基的度量是在 I 坐标系中得出的。从这个视角来看,M×N也不是什么矩阵乘法了,而是声明了一个在M坐标系中量出的另一个坐标系N,其中M本身是在I坐标系中度量出来的。 回过头来说变换的问题。我刚才说,“固定坐标系下一个对象的变换等价于固定对象所处的坐标系变换”,那个“固定对象”我们找到了,就是那个向量。但是坐标系的变换呢?我怎么没看见? 请看: Ma = Ib 我现在要变M为I,怎么变?对了,再前面乘以个M-1,也就是M的逆矩阵。换句话说,你不是有一个坐标系M吗,现在我让它乘以个M-1,变成I,这样一来的话,原来M坐标系中的a在I中一量,就得到b了。 我建议你此时此刻拿起纸笔,画画图,求得对这件事情的理解。比如,你画一个坐标系,x轴上的衡量单位是2,y轴上的衡量单位是3,在这样一个坐标系里,坐标为(1,1)的那一点,实际上就是笛卡尔坐标系里的点(2, 3)。而让它原形毕露的办法,就是把原来那个坐标系: ``` 2 0 0 3 ``` 的x方向度量缩小为原来的1/2,而y方向度量缩小为原来的1/3,这样一来坐标系就变成单位坐标系I了。保持点不变,那个向量现在就变成了(2, 3)了。 怎么能够让“x方向度量缩小为原来的1/2,而y方向度量缩小为原来的1/3”呢?就是让原坐标系: ``` 2 0 0 3 ``` 被矩阵: ``` 1/2 0 0 1/3 ``` 左乘。而这个矩阵就是原矩阵的逆矩阵。 下面我们得出一个重要的结论: “对坐标系施加变换的方法,就是让表示那个坐标系的矩阵与表示那个变化的矩阵相乘。” 再一次的,矩阵的乘法变成了运动的施加。只不过,被施加运动的不再是向量,而是另一个坐标系。 如果你觉得你还搞得清楚,请再想一下刚才已经提到的结论,矩阵MxN,一方面表明坐标系N在运动M下的变换结果,另一方面,把M当成N的前缀,当成N的环境描述,那么就是说,在M坐标系度量下,有另一个坐标系N。这个坐标系N如果放在I坐标系中度量,其结果为坐标系MxN。 在这里,我实际上已经回答了一般人在学习线性代数是最困惑的一个问题,那就是为什么矩阵的乘法要规定成这样。简单地说,是因为: 1. 从变换的观点看,对坐标系N施加M变换,就是把组成坐标系N的每一个向量施加M变换。 2. 从坐标系的观点看,在M坐标系中表现为N的另一个坐标系,这也归结为,对N坐标系基的每一个向量,把它在I坐标系中的坐标找出来,然后汇成一个新的矩阵。 3. 至于矩阵乘以向量为什么要那样规定,那是因为一个在M中度量为a的向量,如果想要恢复在I中的真像,就必须分别与M中的每一个向量进行內积运算。我把这个结论的推导留给感兴趣的朋友吧。应该说,其实到了这一步,已经很容易了。 综合以上1/2/3,矩阵的乘法就得那么规定,一切有根有据,绝不是哪个神经病胡思乱想出来的。 我已经无法说得更多了。矩阵又是坐标系,又是变换。到底是坐标系,还是变换,已经说不清楚了,运动与实体在这里统一了,物质与意识的界限已经消失了,一切归于无法言说,无法定义了。道可道,非常道,名可名,非常名。矩阵是在是不可道之道,不可名之名的东西。到了这个时候,我们不得不承认,我们伟大的线性代数课本上说的矩阵定义,是无比正确的: “矩阵就是由m行n列数放在一起组成的数学对象。” 好了,这基本上就是我想说的全部了。还留下一个行列式的问题。矩阵M的行列式实际上是组成M的各个向量按照平行四边形法则搭成一个n维立方体的体积。对于这一点,我只能感叹于其精妙,却无法揭开其中奥秘了。也许我掌握的数学工具不够,我希望有人能够给我们大家讲解其中的道理了。 我不知道是否讲得足够清楚了,反正这一部分需要您花些功夫去推敲。 此外,请大家不必等待这个系列的后续部分。以我的工作情况而言,近期内很难保证继续投入脑力到这个领域中,尽管我仍然对此兴致浓厚。不过如果还有(四)的话,可能是一些站在应用层面的考虑,比如对计算机图形学相关算法的理解。但是我不承诺这些讨论近期内会出现了。 原文链接:[https://blog.csdn.net/myan/article/details/1865397](https://blog.csdn.net/myan/article/details/1865397 'https://blog.csdn.net/myan/article/details/1865397') https://www.mkshell.com/posts/2018-06-20-architect-learning-reading-writing-separation/ https://www.mkshell.com/posts/2018-06-20-architect-learning-reading-writing-separation.md 2018-06-20 # 学架构笔记14:读写分离 “读写分离”属于高性能数据库架构之一。 近十年来,关系数据库由于其ACID特性和功能强大的SQL,依然是业务系统中的关键和核心存储系统,很多场景下的高性能设计的核心部分依然是关系数据库。 关系数据库厂商在单个数据库服务器的性能方面做了很多的技术优化和改进,但是优化和改进的速度总比不上业务的发展速度,必须要考虑以数据库集群的方式来提升总体性能。 ## 读写分离介绍 读写分离基本架构图: ![](https://source.mkshell.com/15278683926787.jpg) 读写分离的规则: 1. 数据库分为主(Master)、从(Slave)两类,Master一般只有一个 2. 主机负责读写(一般只写),从机只负责读,读写的分发交给业务服务器负责 3. 主机将数据复制给从机,完全同步的状态下每个数据库的数据是完全一样的且存储了所有的数据 ## 难点 按照规则来实现读写分离没有什么问题,主要是有两个难点,设计难点会引入设计的复杂度。 ### 复制延迟 由于主从之间的复制也需要时间,因此在某一个时刻,从机的数据可能比主机的数据少了那么几条。而偏偏在这个时候,用户刚写入主机了却在从机读取数据,最终造成“用户操作成功却感觉失败了”的假象。 解决方法是保证写后读一致性(read-after-write consistency): 1. 在写入后,读取请求发给主机 2. 在从机读取失败则再从主机读取一次 3. 关键业务读写走主机,非关键的才读写分离 更详细的情况可以看看[Replication中常见的数据一致性的问题](https://www.jianyujianyu.com/replication-consistent-data-problems/)。 ### 分配机制 业务服务器将读写分发的逻辑实现有两种方式:程序代码封装、中间件封装。 #### 程序代码封装 指在代码中抽象一个数据访问层,实现读写操作分离和数据库服务器连接管理。 特点: 1. 实现简单,可根据业务定制化 2. 和编程语言有关,多语言时需要多实现 3. 故障情况下(如主从切换),需要改配置且重启 开源实现方案:TDDL(Taobao Distributed Data Layer) #### 中间件封装 独立出一套系统。 此情况下,业务服务器无需区别读写。在业务服务器看来,该中间件就像是一个数据库。 特点: 1. 支持多语言 2. 由于中间件需要支持完整SQL语法和协议,实现复杂 3. 性能不高 4. 业务服务器对主从切换等不感知 开源实现方案:Mysql Proxy、 Mysql Router、 Atlas ## 总结 读写分离的原理、规则,以及要实现的两个难点。 _先这样吧_ 若有错误之处请指出,更多地关注[煎鱼](http://www.jianyujianyu.com)。 https://www.mkshell.com/posts/2018-06-20-arichtect-learning-4-steps/ https://www.mkshell.com/posts/2018-06-20-arichtect-learning-4-steps.md 2018-06-20 # 学架构笔记10、11、12、13:架构设计四部曲 结合复杂度来源和架构设计原则,我们一起来看看实践中如何进行架构设计。 ## 识别复杂度 由于架构设计的目的是为了解决软件系统的复杂性,因此我们在设计架构之前就要先分析系统的复杂度。 如果一个系统的复杂度本来是业务逻辑太复杂,功能耦合严重,架构师却设计了一个TPS达到50000的高性能架构,那么这个设计没有任何意义,没有解决当前的复杂性问题。 高性能、高可用、可扩展等几个方面,一般情况下复杂度只是其中的一个,而如果当前架构需要解决三个或者三个以上的复杂度问题,要么设计时出了问题,要么对当前架构的判断是不正确的。 举个例子,一个(业务普通的)小应用,非要对标腾讯QQ的架构,硬是要设计高TPS高用户量的架构,除了设计和开发时间长外,还出现了各种各样的问题: 1. 系统复杂,运维效率低下 2. 迭代开发效率低 3. 问题定位复杂 4. 目标为高性能,却表现出低性能 同时,面对这样一个烫手山芋,想要改造也不简单: 1. 需要改的地方太多,无从下手 2. 落地时间太久远 3. 多个解决方案冲突,不好操作 这时,最好将主要的复杂度问题都列出来,然后根据实际情况排序,按优先级解决。 存在可能:按优先级解决多个复杂度,前一个问题刚解决成功落地,后一个问题却需要推翻前一个重来。因此,解决方案的敲定也需要斟酌,一般可能存在一个方案或引入一种新技术,能同时解决前后的问题。 ### 案例 假设一个微博like的系统: 1. 用户发微博后,微博子系统通知审核子系统,再通知统计子系统,再通知广告子系统...即在一条微博发出后,微博子系统需要调用十几个子系统的接口。开发、测试效率低下。 2. 如果用户有多个等级,比如VIP、SVIP..(现在很流行有很多种乱七八糟的会员啊),那么上述的调用将会更复杂。 通过对当前架构的了解,分析得问题的根源是系统强耦合,较好的方法就是引入消息队列解耦。 该系统相关人员立项,并总结当前信息: 1. 中间件人员大约6人 2. 团队熟悉Java,少数精通C/C++ 3. 平台选用Linux,数据库MySQL 4. 单机房部署 #### 高性能复杂度分析 假设该系统的用户每天发送1000万条微博,平均一条微博有10个子系统读取,那么子系统读取大约为1亿次。换算过来,每秒写入115条,每秒读取1150条,按峰值三倍算,TPS=345,QPS=3450,总体来说目前阶段性能需求就这样。 考虑后续业务发展,性能需要再向后考虑多一点,按四倍扩展算,TPS=1380,QPS=13800,这种程度下,高性能读取已经是复杂度之一了。 #### 高可用复杂度分析 如审核子系统的高可用是必须要保证的,如等级子系统的高可用的必要性虽然没那么高,但是却十分影响业务的正常功能和用户的满意度。 因此高可用也是需要的。 #### 高可扩展复杂度分析 消息队列整个系统中只充当了一个中间件,只需要保证队列功能即可。因此功能需求较为明确,无需过多考虑可扩展性。 #### 分析总结 该中间件的复杂性主要表现在: - 高性能消息读取 - 高性能消息写入 - 高可用消息存储 - 高可用消息读取 ## 设计备选方案 架构师需要对已存在的技术非常熟悉,对已验证过的架构模式烂熟于心,只有目前的模式无法满足需求的情况下,才需要进行方案创新。而目前大部分情况下,创新方案也是基于已有的成熟技术,如: - NoSQL,KV存储与数据库索引类似 - Hadoop,基础是集群方案+数据复制方案 - Docker虚拟化,基础LXC - LevelDB,其文件存储结构是Skip List > 新技术都是在现有技术的基础上发展起来的,现有技术又来源与先前的技术。将技术进行功能性分组,可以大大简化设计过程,这是技术“模块化”的首要原因。技术的”组合“和”递归“特征,将彻底改变我们对技术本质的认识。 ### 设计备选方案容易犯的错 #### 设计最优秀的方案 架构设计中最常见的错误是“设计最优秀的方案”,即比拼业界最优秀的架构,但适合自己业务、团队、技术能力的方案才是好方案。 #### 只做一个方案 还有一种常见的错误是“只做一个方案”,存在问题: - 可能第二种方案也有优点,却因为简单的评估被筛选掉了 - 架构师个人能力有限导致判断错误,就会导致方案并不是最合适的 - 单一方案设计会出现过度辩护的情况 因此: - 设计备选方案3-5个 - 备选方案之间的差异要明显 - 备选方案不要局限于已经熟悉的技术 #### 备选方案过于详细 第三种常见的错误是“备选方案过于详细”,存在问题: - 会耗费了大量的时间和精力 - 注意了细节会容易忽略整体 - 方案评审容易被细节吸引,导致评审效果不佳 备选方案阶段关注的是技术选型,而不是技术细节,关键是差异要够大。如Zookeeper和Keepalived两种不同技术差异就够大,而Zookeeper的xx方案和xx方案则没有那么大的差异。 ### 案例 故事说到微博系统需要一个消息队列,其复杂度为 - 高性能消息读取 - 高性能消息写入 - 高可用消息存储 - 高可用消息读取 现在就设计备选方案: 1. 采用开源的Kafka,优点为方案成熟 2. 集群 + MySQL存储 + Netty,集群负载均衡满足高性能读取,MySQL主备复制满足高可用存储和高可用读取 3. 集群 + 自研存储方案 + Netty,由于方案中MySQL关系型数据库的特点不是很契合消息队列的数据特点,因此也可以参考Kafka重新设计一套 架构师的技术存储越丰富,备选方案可能就越多,如开源方案也能是ActiveMQ、RabbitMQ,集群也能是HBase、Redis等等。 ## 评估和选择备选方案 在完成备选方案后,如果挑选出最终的方案也较难,因为: - 每个方案都可行,因为不可行的方案是不能作为备选方案的 - 每个方案都有自己的优缺点 - 评价标注主观性比较强,因为复杂和困难是很难去量化的,特别是在对某技术部熟悉的情况下 存在那么几个类型的选择方案,不一定适用于所有场合: - 最简派,哪个原理简单用哪个 - 最牛派,哪个性能好、功能强等等就用哪个 - 最熟派,熟悉哪个选哪个 - 领导派,给领导选 在评估时,遵循“合适原则”、“简单原则”,避免贪大基本上就可以了。同时,不能太过长远地考虑业务暴涨的情况,应该遵循“演化原则”,避免多过地一步到位。如果存在业务暴涨超过预期,那么值得重构。 存在几种看似正确但实际错误的方案: - 数量对比法,哪个方案优点多就选哪个。主要问题是没有考虑优先级 - 加权法。主要问题是评选会渐渐变成数字游戏 正确的做法:按当前业务、团队规模、技能、业务未来发展的实际情况,将质量属性(性能、可用性、成本、安全、可扩展等)按照优先级排序,首先挑选满足第一优先级的,如果都满足再挑选满足第二优先级的,类推。 ### 案例 上回故事说到微博中间件系统设计了三个备选方案,接下来就是方案评选会议。 #### 方案一:采用开源Kafka方案 主管倾向于Kafka方案,因为成熟。 中间件团队支持Kafka,因为能节省开发投入,但部分人员认为Kafka不太适合业务场景。 运维反对Kafka,因为运维团队没有Scala的开发经验,问题处理困难。 测试倾向于Kafka,因为成熟。 #### 方案二:集群 + MySQL存储 中间件研发人员认为较简单,部分对性能持怀疑态度,并觉得用MySQL做消息队列很low。 运维团队赞同该方案,因为MySQL运维难度低。 测试代表认为该方案需要投入较大的人力,包括功能测试、性能测试、可靠性测试。 主管不肯定也不否定,只要保证业务稳定可靠即可。 #### 方案三:集群 + 自研存储系统 中间件团队较为支持,是展现团队实例的一个好机会,性能比方案二要好,但人力研发成本较大、迭代时间较长。 运维不赞成,因为不相信研发团队实力,容易造成运维失误。 测试团队赞同运维团队的意见,测试难度大。 主管保留一键,新系统一般迭代较差。 #### 根据属性评审 | 质量属性 | 方案一 | 方案二 | 方案三 | | :------- | :-------------------------------------- | :-------------------------------------- | :---------------- | | 性能 | 高 | 中 | 高 | | 复杂度 | 低,开箱即用 | 中 | 高,研发成本高 | | 硬件成本 | 低 | 高,集群部署 | 低,和Kafka一样 | | 可运维性 | 低,无法融入现有运维体系,且无Scala经验 | 高,可以融入现有运维体系,MySQL经验成熟 | 高,无需维护MySQL | | 可靠性 | 高,成熟方案 | 高,MySQL成熟方案 | 低,自研需要迭代 | | 人力投入 | 低 | 中,开发集群服务器 | 高 | 架构师选择了方案二,原因有: 1. 从可运维性排除了方案一,如果上线了出问题不能技术修复就无法满足业务需求,且Kafka的设计目标为日志传输而非消息传输。 2. 从复杂度排除方案三,因为人力成本不足。 3. 方案二优点是复杂度不高,可靠性有保障。 但方案二也有缺点: 1. 性能容易到瓶颈,但目前业务不高 2. 硬件成本较高 3. 技术不优越,但是适应业务 ### 评审总结 备选方案的选择和很多因素有关,并不能单纯地考虑技术因素,不同的业务会造成不同的选择。 ## 详细方案设计 详细方案设计就是在选择方案后,将方案设计的关键技术细节给确定下来。 举几个例子: - 如果确定使用Elasticsearch全文搜索,那么就要确认的索引是按照业务划分的还是直接用一个大索引,副本数量和集群节点数量是多少。 - 如果确定使用MySQL分库分表,那么就要确定那些表要分,按哪个维度分,分了之后的处理方法。 - 如果确定引入Nginx来做负载均衡,那么Nginx的主备怎么做,负载均衡的策略用哪个。 看起来和备选方案类似,但简单了一点,因为无需选择,只需要简单地根据原物场景选择详细技术就好。 详细设计方案阶段可能会遇到一种情况,就是发现备选方案不可行,可能是因为在设计时遗漏,可以通过如下方法有效避免: - 架构师不但要进行备选方案设计还要对备选方案的关键细节有较深的了解。 - 通过分步骤、分阶段、分系统,能有效降低方案的复杂度。 - 如果方案本身很复杂,那么就采取设计团队的方式进行设计,即避免一两人出现了盲区导致方案出问题。 ### 案例 上回故事去到微博消息系统根据方案二设计详细方案。 #### 数据库表的设计 - 设计两类表,一类是日志表,用于消息写入时快速存储到MySQL,另一类是消息表,每个消息队列一张表。 - 发布消息时,先写入日志表,日志表写入成功就代表消息写入成功,后台线程从日志表中读取消息写入消息表中。 - 读取消息时,从消息表中读取。 - 日志表包含字段有:日志ID、发布者信息、发布时间、队列名称、消息内容 - 消息表包含字段有: 消息ID、消息内容、消息发布时间、消息发布者 - 日志表需要及时清楚已经写入消息表的日志,消息表可以设置保存较长的时间 #### 数据如何复制 直接采用MySQL主从复制,而且只复制消息表。 #### 主备服务器如何倒换 采用Zookeeper来做主备决策,主备服务器都连接到Zookeeper建立自己的节点,主服务器路径规则为`/MQ/server/{id}/master`,备机为`/MQ/server/{id}/slave`,节点类型为EPHEMERAL。 当发现主服务器断开,备机修改自己的状态,对外提供消息读取服务。 #### 业务服务器写入消息的方法 消息队列提供SDK供各业务系统调用,SDK从配置中读取所有消息队列系统的服务器信息,SDK轮询发起写入请求给主服务器,如果请求错误则请求下一台。 #### 业务服务器读取消息的方法 消息队列提供SDK供各业务系统调用,轮询方案与写入类似。 消息队列服务器需要记录每个消费者的消费状态,即当前消费者已经读取到哪条消息,当收到消息读取请求时,返回下一条未读消息给消费者 #### 业务服务器和消息队列服务器之间的通信协议 为了提升兼容性,传输协议用TCP,数据格式使用ProtocolBuffer。 ## 总结 学习了架构设计四部曲,分别是识别复杂度、设计备选方案、评估和选择备选方案、详细方案设计,以及通过一个微博消息系统的方案设计案例,更深入地熟悉架构设计。 _先这样吧_ 若有错误之处请指出,更多地关注[煎鱼](http://www.jianyujianyu.com)。 https://www.mkshell.com/posts/2018-06-15-arichtect-learning-3-principle-example/ https://www.mkshell.com/posts/2018-06-15-arichtect-learning-3-principle-example.md 2018-06-15 # 学架构笔记8和9:架构设计三原则以及案例 编程和架构最大的区别是“不确定性”,编程写出来的程序是确定的,而不同的架构可能都适合于某个场景。 由于不同的架构都可能或看似可能适用于需求,或者团队成员又更熟悉某种技术栈,多种选择困难症压在架构师身上。这些选择哪一种都可能正确,但是哪一种合适却没有一套通用的规范,大多数都是依赖经验和直觉。 ## 三个原则 ### 合适原则 在设计架构的时候,不能总是瞄准着大厂的水准,总是想成为业界领先。但往往这样会忽略了业务的实际需求,而放大了工作量,导致失败。 1. 往往没有业务量,就不会有太多的人力资源去支撑“大架构” 2. 业界顶尖的架构都是通过逐步完善得出的 3. 好的架构不是想出来的,是业务场景和压力的情况下逼出来的 脱离了业务,空谈架构反而做不出成绩。 ### 简单原则 团队压力有时会有意无意地促进我们走向复杂,如Zookeeper的主备策略让人总比心跳更高大上,导致更多的人觉得更可靠而选择更复杂的方案。 软件领域的复杂性体现在结构的复杂性和逻辑的复杂性。 #### 结构的复杂性 结构复杂的系统一般有以下特点: 1. 组件数量多 2. 组件之间的关系复杂 3. 定位组件问题困难 对于解决结构复杂性的问题,我们第一想法是将组件数量减少,但是过少也会引入新问题。 #### 逻辑的复杂性 如果当个组件(或对象或函数)的逻辑过于复杂,也会带来问题。比如可扩展性问题、协作问题。 通常,企业发展起来构架的第一次改革都是因为组件过于复杂,需要对系统进行分割。 功能复杂的组件还有可能采用了复杂的算法,导致难以理解、难以改进。 ### 演化原则 据说,软件架构起源于建筑学。但是建筑一旦完成就不可变,而软件却还要根据业务的发展不断地变化。 而架构本质上不能一成不变,因为业务总是在变化。因此,也不会说照搬了某个企业的架构就能一劳永逸。 软件架构都需要经历以下过程: 1. 设计出来的架构满足当前业务 2. 架构不断迭代,取其精华去其糟粕 3. 也许要重构重写,但是总有东西能延续 ## 案例 ### 淘宝 淘宝技术发展经历了“个人网站”-> “Oracle/支付宝/旺旺”->“Java时代1.0”->“Java时代2.0”->“Java时代3.0”->“分布式时代” #### 个人网站 2003年,淘宝赶着上线,就直接买一个产品和服务。没有太多的时间去考虑性能、稳定性什么的,因为项目落地、快速上线、抢占市场就是第一需求。 ![](https://source.mkshell.com/15290417695274.jpg) 这里淘宝其实遵循着“合适原则”和“简单原则”。 #### Oracle/支付宝/旺旺 此时距离之前的上线还只是半年不到,根据“合适原则”和“简单原则”,最简单的方式还是买买买。 由于网购火爆,业务剧增,MySQL撑不住了,于是换成了Oracle,容量大、稳定、安全、性能高。 而数据量大存储不足,则购买NAS,在加上Oracle RAC实现负载均衡。 ![](https://source.mkshell.com/15290420416513.jpg) #### Java时代1.0 起初选择Java的原因是:PHP连接Oracle的开源库(SQL Relay)总是死锁。 > PHP + Apache每次请求都会对数据库产生一个链接,没有连接池,如此性能低效,因此选择了SQL Relay。 而这个技术的问题严重影响了业务的发展,不得不解决。 回顾这次改革,选择Java同时也算是打开了人才的道路,因为招聘比Java好不少。 ![](https://source.mkshell.com/15290423923519.jpg) #### Java时代2.0 业务发展到这个时间,买买买已经不能解决容量、性能、成本问题了。 在性能问题上,Oracle也始终没有文本搜索的性能,而成本问题更是致命的。 根据“演化原则”,技术人员需要回顾并重构。 ![](https://source.mkshell.com/15290426033565.jpg) 淘宝也做了很多优化工作,如数据分库、放弃EJB、引入Spring、加入缓存、加入CDN等等。 #### Java时代3.0 此时业务规模足以让技术人员去IOE(IBM、Oracle、EMC),就是开始了自研技术,可以大幅度地降低技术成本。 ### 手机QQ 手机QQ的发展粗略分为四个阶段:十万级、百万级、千万级、亿级。基本上是用户规模上去,产生问题,倒逼技术架构升级。 #### 十万级 最开始的手机QQ后台是一个普通的架构,遵循着“合适原则”和“简单原则”。 ![](https://source.mkshell.com/15290432323622.jpg) #### 百万级 2001年,QQ同时在线人数突破一百万,存在问题: 1. 接入服务器性能吃紧,内存不足 2. CPU、网卡带宽、流量到了瓶颈 3. 单台服务器支持不了所有的在线用户(注册用户也存不下) 按照“演化原则”,进行重构: ![](https://source.mkshell.com/15290433716224.jpg) #### 千万级 2005年,QQ同时在线人数突破千万,出现新问题: 1. 同步流量太大,状态同步服务器遇到了单机瓶颈 2. 在线状态信息量太大导致单台服务器存储不足 3. 单台状态同步服务器支撑不了所有的在线用户 4. 单台接入服务器支撑不了所有在线用户的在线状态信息 架构再一次“演化”: ![](https://source.mkshell.com/15290435251484.jpg) #### 亿级 2010年,QQ同时在线人数过亿,出现问题: 1. 灵活性差,功能扩展困难 2. 无法支撑某些关键功能 3. 之前一直在原基础上升级,现在需要重新设计 于是,“演化”出新架构 存储架构: ![](https://source.mkshell.com/15290436741344.jpg) 通信架构: ![](https://source.mkshell.com/15290436905683.jpg) ## 总结 学习了面对“不确定性”时架构设计的三个原则以及两个案例。 _先这样吧_ 若有错误之处请指出,更多地关注[煎鱼](http://www.jianyujianyu.com)。 https://www.mkshell.com/posts/2018-06-03-talking-about-why-service-govern-dubbo/ https://www.mkshell.com/posts/2018-06-03-talking-about-why-service-govern-dubbo.md 2018-06-03 # 谈谈为什么需要服务治理(Dubbo) 服务治理主要针对于当前分布式架构下多服务、微服务等。 服务是分布式系统下的一个不大不小的部分,有了服务的组成,整个系统才能活起来。 随着业务的增长,服务不能一味地随之增长,需要管理、治理。没有服务治理的分布式系统不一定会失败,但是随着业务的增长,这个系统一定会很痛苦。 ## 服务治理的目标 服务治理严格意义上应该划分为三个阶段,包含了服务的整个生命周期。 其中服务设计期主要针对于服务的设计期、开发期,而服务运行期主要针对于服务上线后等运行情况,最后服务持续治理则是坚持了“分久必合”的理念,将淘汰制进行到底。 下面讲讲三个时期需要完整的工作: **服务设计期:** 1. 方案评审、开发测试审查、签发认证、服务可发现 2. 策略管理 3. 合约定义、商谈 4. 标准化服务质量协议 **服务运行期:** 1. 系统记录:记录交换的信息 2. 服务管理系统:控管、配置服务以及运行阶段的组件,根据异常状况重新配置环境 3. 服务监控系统:采集数据,可视化,提供变配证据 4. 服务质量保证系统:增强通讯中的消息和运行阶段策略、安全性、可靠性、事务性、稽核等 **服务持续治理:** 1. 服务资产管理:评估、分析服务仓库,识别服务可重用的机率、协助进行资产整合、减少冗余的服务功能 **根据上述目标,我们可以确定:** 1. 服务治理贯穿了服务的整个生命周期,包括开发前的设计、开发以及测试、运行、以及后续管理。 2. 服务设计期主要针对于服务的设计评审以及标准的制定。 3. 服务治理运行期的重点放在管理和监控,为了运行良好的目标,通过数据分析运行状况,通过自动化消除异常、变配等。 4. 服务治理后期的重点放在消除冗余。 ## 服务治理平台设计 结合现在大多架构的注册中心、监控中心,可构设出大概的[架构图](http://www.jianyujianyu.com): ![架构图](https://source.mkshell.com/架构图.png) ## 结合Dubbo分析 在服务治理平台的开发过程中,开发难点和设计服务复杂度应该放在了服务注册、服务监控上。 Dubbo是一个高性能服务框架,致力于提供高性能和透明化的RPC远程服务调用方案,以及SOA服务治理方案,使得应用可通过高性能RPC实现服务的输出和输入功能,和Spring框架可以无缝集成。 ### 问题分析 随着业务不断增长,为了追求更高的性能支撑业务,集群的引入使得服务架构的复杂度大大提升。庞大的集群容易出现各种各样的问题: 1. 过多的服务URL配置困难 2. 负载均衡分配节点压力过大的情况下也需要部署集群 3. 服务依赖混乱,启动顺序不清晰 4. 过多服务导致性能指标分析难度较大,需要监控 ### 架构分析 ![](https://source.mkshell.com/15280311786014.jpg) Dubbo注册中心和监控中心的引入是服务治理的关键。 **注册中心的关键点:** - 服务提供者向注册中心注册其提供的服务 - 服务消费者向注册中心获取服务提供者地址列表,同时加上负载均衡的算法选择服务提供者 **监控中心的关键点:** - 服务消费者和提供者累计调用次数和调用时间,定时发送统计数据到监控中心 业务引入架构后,必须要保证的是,对当前业务的稳定性的影响只能是正面影响或者无影响,不能是负面影响。 考虑该架构对**稳定性**的影响: - 注册中心宕机情况下,消费者在本地缓存了提供者列表,业务暂时不受影响,但是不能再注册新的服务 - 监控中心宕机情况下,不影响服务,只影响部分采样数据 - 服务提供方宕机后,通过负载均衡算法可将请求往别的同服务的提供方发送,对健壮性起正面作用 注册中心和监控中心的引入在很大程度上提高了运行期的稳定性,对应了服务治理的工作。 考虑架构对**其他方面**的影响: - 可动态增加服务,由注册中心统一动态分配 - 可动态增加消费方,由注册中心统一动态分配 由此可见注册中心的引入提高了伸缩性,对应了服务治理运行期所需工作。 而监控中心的引入,数据的采集和分析得到的收益也是明显的,对应的是服务治理运行期的服务监控以及服务治理持续治理下的服务资产管理。 _先这样吧_ 若有错误之处请指出,更多地关注[煎鱼](http://www.jianyujianyu.com)。 https://www.mkshell.com/posts/2018-05-29-talk-about-clojure-simply/ https://www.mkshell.com/posts/2018-05-29-talk-about-clojure-simply.md 2018-05-29 # 简简单单说个闭包 ## 闭包的作用 一句话,闭包的作用:**将方法存于变量。** 至于闭包的原因或者目的,或者说,为什么将方法存于变量,稍后再说。 ## 闭包的条件 为了尽量避免用一大段话描述一个概念,我们理性一点地把闭包的条件划分成3个: 1. 外函数中定义了一个内函数 2. 内函数用了外函数的变量 3. 外函数返回了内函数的引用,or,外函数中直接调用了内函数 P.S. 1. 其中外函数和内函数是指嵌套函数中外部函数和内部函数 2. 也正是因为需要嵌套函数,因此不支持的嵌套函数的语言也自然不支持此类闭包 3. 条件3中分成了两类,更多的情况下是前一类,而后一类(外函数直接调用了内函数)的使用更多的是为了保证代码的简洁,而如此地保持简洁并不一定用闭包。 ## 闭包的例子 “Talk is cheap, show me your code.” 我始终觉得,在编程中,过多的人类语言会产生太多的歧义,甚至还可能会因为所说事物过于抽象而导致听众无法将概念理解。 而解决这个问题最好的方法就是看代码,编程语言相较于人类语言的优点之一是大幅地降低了语言的歧义。同时,通过多个代码实例,人脑会自然而然地将多实例中的共同点提取出来,进而理解抽象的概念。 结合闭包的三个条件,我们来看看闭包的例子: ```python def outer(b): def inner(a): # 条件1 print(a + b) # 条件2 return inner # 条件3 # 调用 o = outer(1) o(2) ``` Python的代码还是挺简单的。 ![](https://source.mkshell.com/15275953263325.jpg) 一般情况下,在函数结束后,函数中变量等就应该被销毁,偏偏这个闭包就是个特例 —— o和o2中的1和20都保留着。 o和o2看起来就有那么一丝熟悉的感觉,它们两个就像是两个对象 —— 这两个“对象”都是从同一个“类”出来的,而两个“对象实例”的区别是有一个加数不一样,分别是1和20(当然,这两个变量的引用地址也不同)。 现在,我们把代码例子中的第三个条件变一下,即将“外函数返回了内函数的引用”变成“外函数中直接调用了内函数”: ```python def outer(a, b): def inner(a): # 条件1 print(a + b) # 条件2 inner(b) # 条件3 # 调用 o = outer(100,1) ``` ![](https://source.mkshell.com/15275967458359.jpg) 此时,整个闭包函数调用起来就和一个普通函数一样,传入两个参数,该print的也如期而至。 只能说,在outer函数内的逻辑过于复杂的时候,inner能把复杂的代码“模块化”,再调用,能增加简洁性。这种情况下,一般是inner函数只被调用一次,而且只在这里调用,放在这里也好管理一些。 接下来,我们也鉴赏一下别的语言类似的闭包: ```go func outer(i int) func() int { return func() int { // 条件1(匿名)+ 条件3 i++ // 条件2 return i } } // 调用 o := outer(1) o() ``` 这个Golang的例子闭包和Python的例子较大的距别是这里还把内函数换成了匿名的,看起来会爽点。而以下的php的就和Python的差不多了。 ```php function outer($str1) { $outerStr = $str1; $inner = function($str2) { // 条件1 echo $str2 . $outerStr; // 条件2 }; return $inner; // 条件3 } // 调用 $o = outer("hahaha"); $o("emmm"); ``` ## 闭包的原因 看过了上面的例子后,新手对闭包的概念也应该有了一定的理解,甚至有点想法了。 回到最开始的问题,即闭包的原因。 如果说,“将方法存于变量”是闭包的目的,那么接下来的问题显而易见:为什么要将方法存于变量?直接调用方法(函数)不好吗? ### 闭包保存了函数的状态信息 再举开头的例子: ```python def outer(b): def inner(a): print(a + b) return inner o = outer(1) o(2) # 3 o(100) # 101 o2 = outer(20) o2(100) # 120 ``` o这个变量对应的闭包保存了`b=1`这个信息,之后无论是调用`o(2)`还是`o(100)`,`b=1`这个信息依然会存在并和后来的参数一起参与运算。同理,o2这个变量对应的闭包保存了`b=20`这个信息。 由于退出了函数后,函数并没有并销毁,这个闭包的信息也没销毁,因此后续可以利用这些信息。 ### 语法糖 为了代码的简洁性和易理解性,我们经常会使用甚至创造一些语法糖。 而在Python中,有一个十分好看的例子就是装饰器,举个已经被用烂了的例子,面向切面的登录实现: ```python # 先实现一个类似于装饰器的函数 def decorator(func): def inner(): print 'before function' func() # function print 'after function' return inner # 实现一个假装在登录的登录函数 def login(): print 'login function complete.' # 将登录函数“套上”装饰器 login = decorator(login) login() ``` 整个过程下来与AOP类似,而场景也很常用,如统计函数的运行时常、加入日志、统一的过滤处理等等。 更有甚者,在特定的场景下使用闭包创造语法糖,以简化代码,参考这个例子,该例子可以替代switch(不过这里这么简单的加减运算这样写就很智障了,要有一定的复杂度就能显得有优越性): ```python def operator(o): def plus(x, y): print(x + y) def minus(x, y): print(x - y) if o == '+': return plus if o == '-': return minus def f(x, o, y): operator(o)(x, y) ``` ### 函数式编程 鼎鼎大名的Lambda,这个可以参考下[这个链接](https://github.com/kachayev/fn.py)。 ## 总结 闭包能将方法存于变量,且实现一些美妙的东西。 它就像是调味剂,并非不可或缺,但是能锦上添花。 _先这样吧_ 若有错误之处请指出,更多地关注[煎鱼](http://www.jianyujianyu.com)。 https://www.mkshell.com/posts/2018-05-22-linux-dmesg-command-introduction/ https://www.mkshell.com/posts/2018-05-22-linux-dmesg-command-introduction.md 2018-05-22 # Linux dmesg命令介绍 dmesg命令是用来在[Unix-like](https://baike.baidu.com/item/%E7%B1%BBUNIX%E7%B3%BB%E7%BB%9F/4336219?fr=aladdin 'Unix-like')系统中显示内核的相关信息的。dmesg全称是display message (or display driver),即显示信息。 实际上,dmesg命令是从内核环形缓冲区中获取数据的。当我们在Linux上排除故障时,dmesg命令会十分方便,它能很好地帮我们鉴别硬件相关的error和warning。除此之外,dmesg命令还能打印出守护进程相关的信息,已帮助我们debug。 本文中,我们会讨论一下比较实用的几个dmesg具体命令。 ## 默认显示选项 ``` dmesg ``` 这个是Mac下的dmesg命令: ![](https://source.mkshell.com/15270039201978.jpg) 这是Linux(树莓派)下的dmesg命令: ![](https://source.mkshell.com/15270039867246.jpg) 需要注意的是,某些情况下(如在Mac下),需要sudo。 如果太长了可以像以上一样后接管道和head命令,或后接管道和less或more。 ## 显示和内存、硬盘、USB、TTY相关的信息 ``` dmesg | grep -i memory dmesg | grep -i dma dmesg | grep -i usb dmesg | grep -i tty ``` 好吧,这个不应该算是dmesg的技巧,应该算是linux管道的技巧。 如果全都要,可以: ``` dmesg | grep -E "memory|dma|usb|tty" ``` 然后输出大概如下: ![](https://source.mkshell.com/15270043770441.jpg) ## 读后删选项 如果想要在读取dmesg后,删除已读的日志,可以加上`-C`选项: ``` dmesg -C ``` ## 输出日志显示颜色 如果输出的日志是纯白或纯黑的,就会显得很不友善,想要colorful就可以加上`-L`选项: ``` dmesg -L ``` **_实际情况是,我试了一下根本没有显示颜色,而help里面还说color是默认开启的,迷惑中。_** ## 只输出特定类型的信息 比如说,我只想输出守护进程的信息: ``` dmesg --facility=daemon ``` 效果如下: ![](https://source.mkshell.com/15270048849324.jpg) 其中facility选项还可以填入,kern、user、mail、daemon、auth、syslog、lpr、news。 **_奇怪,现在倒是默认地显示出颜色来了。_** ## 只输出特定级别的信息 有的时候我们只要要error以及warning,不要别的等级的信息,可以使用如下命令: ``` dmesg --level=err,warn ``` 然后系统的error以及程序员都不看的warning就展示了出来: ![](https://source.mkshell.com/15270051438942.jpg) level选项还可以填入别的等级,例如emerg、alert、crit、err、warn、notice、info、debug。 ## 显示时间戳 需要注意到的是,dmesg命令默认的时间显示是从开机到log记录的时间,说实话,这样对于人类来说真的不方便,我们需要时间: ``` dmesg -T ``` ![](https://source.mkshell.com/15270064538788.jpg) 相信我,舒服多了。 更进一步,把级别也显示出来: ``` dmesg -Tx ``` ![](https://source.mkshell.com/15270065351760.jpg) ## 显示原始数据 dmesg默认输出的信息是处理过的,想要显示原始数据(raw data): ``` dmesg -r ``` ![](https://source.mkshell.com/15270067233801.jpg) **_根据原始数据,我们不难猜出,<6>就是info级别、<5>就是notice级别等等的转译细节。_** _先这样吧_ 若有错误之处请指出,更多地关注[煎鱼](http://www.jianyujianyu.com)。 https://www.mkshell.com/posts/2018-05-20-open-nsfw-on-rpi/ https://www.mkshell.com/posts/2018-05-20-open-nsfw-on-rpi.md 2018-05-20 # 小小树莓派鉴黄初体验 OpenNSFW on RPi **_OpenNSFW是雅虎数年前已经训练好的一个鉴黄模型(是的只是模型而已,素材请自行解决)。既然已经在小树莓派上搭建好了Caffe,那么何不试试基于Caffe的鉴黄模型?_** ## Caffe环境 关于树莓派(64位的,32位可参考)Caffe环境的搭建,[之前的一遍文章](https://www.jianyujianyu.com/raspberry-pi-3bplus-64-ubuntu-caffe/)已经谈过,可以去参考一下。 另外,需要把Caffe的Python接口也编译了,即pycaffe环境: ``` # 在caffe目录下执行 make pycaffe -j4 ``` 然后记得往PYTHONPATH里面添加此库: ``` vim ~/.zshrc vim ~/.bashrc export PYTHONPATH=/path/to/caffe/python:$PYTHONPATH ``` 检验pycaffe库是否配置正确:在python中import一下,如下即可。 ![](https://source.mkshell.com/15268225142053.jpg) 如果在编译pycaffe、添加了PYTHONPATH后,在import时出现`No module named xxxx`的情况,原因是python库还没装好,方法: 1. 在`caffe/python`目录下执行`pip install -r requirements.txt` 2. 使用apt安装确实的python包 ## OpenNSFW 找个地方克隆下这个关键的模型,由于该模型略大,因此git-clone还是会挺久的,有障碍的话可以直接下载zip包。 ``` git clone https://github.com/yahoo/open_nsfw.git ``` 克隆或者解压后进入目录,然后准备一下素材图片: ![](https://source.mkshell.com/15268331210324.jpg) 然后执行命令: ``` python ./classify_nsfw.py --model_def nsfw_model/deploy.prototxt --pretrained_model nsfw_model/resnet_50_1by2_nsfw.caffemodel INPUT_IMAGE_PATH ``` 把其中的`INPUT_IMAGE_PATH`改成图片路径即可,比如此处换成`test1.png`即可,就会得到该图片涉黄的概率: ![test1](https://source.mkshell.com/test1.png) ![](https://source.mkshell.com/15268332814413.jpg) 从结果中看出,XX得分是0.6067,个人可以将它理解为XX概率,即60%的XX概率。 再举个例子(抱歉,重码!): ![test2](https://source.mkshell.com/test2.png) ![](https://source.mkshell.com/15268333550838.jpg) 幸好重码,不然涉黄分数都高达0.8488了。。 再来举个反例试试吧(这次就不用打码了): ![test3](https://source.mkshell.com/test3.png) 得分: ![](https://source.mkshell.com/15268336242410.jpg) 小到不敢相信,诚不欺我!! ## 初体验总结 试了一下,图像检测的时候资源耗用也没有想象中那么高,似乎也就几百M内存,以及一点点计算资源,连树莓派这样的小机器都能在数秒内检测完。相信结合其他东西会更好玩。 _先这样吧_ 若有错误之处请指出,更多地关注[煎鱼](http://www.jianyujianyu.com)。 https://www.mkshell.com/posts/2018-05-18-python-opencv-face-recognition/ https://www.mkshell.com/posts/2018-05-18-python-opencv-face-recognition.md 2018-05-18 # Python-OpenCV进行人脸识别 在之前的文章中,我们学习了使用数据集训练出一个识别器。本文中,我们将载入这个识别器,然后来看见怎么识别人脸。 如果[看过之前的文章](https://www.jianyujianyu.com/python-opencv-train-face-recognizer/),你就已经准备好了一个识别器,它就在trainner文件夹和trainner.yml文件里面。 现在,我们将使用这个训练好的文件去识别人脸了。 ## 导入 ``` import cv2 import numpy as np ``` ## 加载识别器 接下来,我们用OpenCV库以及我们训练好的数据(yml文件)创建一个识别器对象: ``` recognizer = cv2.face.LBPHFaceRecognizer_create() # recognizer = cv2.createLBPHFaceRecognizer() # in OpenCV 2 recognizer.read('trainner/trainner.yml') # recognizer.load('trainner/trainner.yml') # in OpenCV 2 ``` 然后用之前准备好的xml创建一个分类器: ``` cascade_path = "haarcascade_frontalface_default.xml" face_cascade = cv2.CascadeClassifier(cascade_path) ``` 获取到摄像头的控制对象: ``` cam = cv2.VideoCapture(0) ``` 加载一个字体,用于在识别后,在图片上标注出识别对象的名字: ``` # font = cv2.cv.InitFont(cv2.cv.CV_FONT_HERSHEY_SIMPLEX, 1, 1, 0, 1, 1) font = cv2.FONT_HERSHEY_SIMPLEX ``` ## 识别程序的主循环 在程序的主循环中,我们需要做的是: - 从摄像头中获取图像 - 将图像转换为灰度图片 - 在图片中检测人脸 - 用识别器识别该人的id - 将识别出人脸的id或名称用矩形在图片中标出来 ``` while True: ret, im = cam.read() gray = cv2.cvtColor(im, cv2.COLOR_BGR2GRAY) faces = face_cascade.detectMultiScale(gray, 1.2, 5) for (x, y, w, h) in faces: cv2.rectangle(im, (x - 50, y - 50), (x + w + 50, y + h + 50), (225, 0, 0), 2) img_id, conf = recognizer.predict(gray[y:y + h, x:x + w]) # cv2.cv.PutText(cv2.cv.fromarray(im), str(Id), (x, y + h), font, 255) cv2.putText(im, str(img_id), (x, y + h), font, 0.55, (0, 255, 0), 1) cv2.imshow('im', im) if cv2.waitKey(10) & 0xFF == ord('q'): break ``` `recognizer.predict`为预测函数,`putText`则是在图片上添加文字 ## 更进一步 由于可能识别不出来,或者存在未知的人脸。而且,如果只用id1,id2就会大大地降低了程序的体验。因此,我们可以把id换成名字,把未知的脸标为未知。 我们把程序改成: ``` img_id, conf = recognizer.predict(gray[y:y + h, x:x + w]) if conf > 50: if img_id == 1: img_id = 'jianyujianyu' elif img_id == 2: img_id = 'ghost' else: img_id = "Unknown" # cv2.cv.PutText(cv2.cv.fromarray(im), str(Id), (x, y + h), font, 255) cv2.putText(im, str(img_id), (x, y + h), font, 0.55, (0, 255, 0), 1) ``` ## 释放资源 记得释放资源 ``` cam.release() cv2.destroyAllWindows() ``` ## 测试 然后在测试阶段,这个人工智障完美地识别不出我。 ![](https://source.mkshell.com/15266221700753.jpg) 我觉得是素材不够丰富,我回头改改。。。 ## 完整代码 现在的目录: ![](https://source.mkshell.com/15266224463023.jpg) ``` import cv2 import numpy as np recognizer = cv2.face.LBPHFaceRecognizer_create() # recognizer = cv2.createLBPHFaceRecognizer() # in OpenCV 2 recognizer.read('trainner/trainner.yml') # recognizer.load('trainner/trainner.yml') # in OpenCV 2 cascade_path = "haarcascade_frontalface_default.xml" face_cascade = cv2.CascadeClassifier(cascade_path) cam = cv2.VideoCapture(0) # font = cv2.cv.InitFont(cv2.cv.CV_FONT_HERSHEY_SIMPLEX, 1, 1, 0, 1, 1) # in OpenCV 2 font = cv2.FONT_HERSHEY_SIMPLEX while True: ret, im = cam.read() gray = cv2.cvtColor(im, cv2.COLOR_BGR2GRAY) faces = face_cascade.detectMultiScale(gray, 1.2, 5) for (x, y, w, h) in faces: cv2.rectangle(im, (x - 50, y - 50), (x + w + 50, y + h + 50), (225, 0, 0), 2) img_id, conf = recognizer.predict(gray[y:y + h, x:x + w]) if conf > 50: if img_id == 1: img_id = 'jianyujianyu' elif img_id == 2: img_id = 'ghost' else: img_id = "Unknown" # cv2.cv.PutText(cv2.cv.fromarray(im), str(Id), (x, y + h), font, 255) cv2.putText(im, str(img_id), (x, y + h), font, 0.55, (0, 255, 0), 1) cv2.imshow('im', im) if cv2.waitKey(10) & 0xFF == ord('q'): break cam.release() cv2.destroyAllWindows() ``` _先这样吧_ [原文](https://thecodacus.com/face-recognition-loading-recognizer/),若有错误之处请指出,更多地关注[煎鱼](http://www.jianyujianyu.com)。 https://www.mkshell.com/posts/2018-05-17-python-opencv-train-face-recognizer/ https://www.mkshell.com/posts/2018-05-17-python-opencv-train-face-recognizer.md 2018-05-17 # Python-OpenCV训练一个人脸识别器 想要能人脸识别,我们需要训练一个识别器处理。训练的话就需要之前已经标注好的训练集,在[前一篇文章](https://www.jianyujianyu.com/face-recognition-dataset-generator/)中,我们创建了一个已经标注好的训练集。现在,是时候用这个训练集来训练一个人脸识别器了。当然,是用OpenCV Python。 ## 准备 首先,我们在(前一篇文章的)同目录下创建一个叫Python文件,名为trainner.py,用于编写数据集生成脚本。同目录下,创建一个文件夹,名为trainner,用于存放我们训练后的识别器。 现在,项目目录大概如此: ![](https://source.mkshell.com/15265263830787.jpg) 其他乱七八糟的文件、目录,都是之前的文章中创建的。 在开始之前,我们先安装一个Python库,Pillow: ``` pip install pillow ``` ## 开始Coding ### 导入库 编写训练程序需要先做是: 1. 导入opencv库 2. 导入os库,用于方法文件 3. 导入numpy库,用于计算 4. 导入pillow库,用于处理图像 其实就是这样: ``` import cv2 import os import numpy as np from PIL import Image ``` 现在我们初始化识别器和人脸检测器: ``` recognizer = cv2.face.LBPHFaceRecognizer_create() # 有可能是 recognizer = cv2.createLBPHFaceRecognizer() detector = cv2.CascadeClassifier("haarcascade_frontalface_default.xml") ``` 如果face.LBPHFaceRecognizer_create或createLBPHFaceRecognizer显示不存在,则需要下载opencv-contrib-python: ``` pip install opencv-contrib-python ``` 当然用IDE的也行: ![](https://source.mkshell.com/15265427455379.jpg) ### 载入训练数据 恩,现在我们来创建一个函数,用于从数据集文件夹中获取训练图片,然后从图片的文件名中获取到这个素材相应的id。需要remind的是,根据[前文](https://www.jianyujianyu.com/face-recognition-dataset-generator/),图片的格式是`User.id.samplenumber`。 给函数起个名字,就叫get_images_and_labels吧(Python不建议用驼峰),然后参数需要有素材的文件夹: ``` def get_images_and_labels(path): ``` 在函数中,我们需要的做的有: 1. 从数据集文件夹中载入训练图片 2. 获取到人脸和id 3. 整理成list并返回 获取图片: ``` image_paths=[os.path.join(path, f) for f in os.listdir(path)] ``` 新建两个list用于存放: ``` face_samples=[] ids=[] ``` 遍历图片路径,导入图片和id,添加到list: ``` for image_path in image_paths: image = Image.open(image_path).convert('L') image_np = np.array(image, 'uint8') image_id = int(os.path.split(image_path)[-1].split(".")[1]) faces = detector.detectMultiScale(image_np) for (x, y, w, h) in faces: face_samples.append(image_np[y:y + h, x:x + w]) ids.append(image_id) ``` 以上代码,使用了`Image.open(image_path).convert(‘L’)`通过图片路径并将其转换为灰度图片。 接下来我们通过`image_np = np.array(image, 'uint8')`将图片转换成了Numpy数组,Numpy数组的逻辑结构和普通的数组无异,但是是经过优化的。 为了获取到id,我们将图片的路径分裂一下并获取相关信息,即`image_id = int(os.path.split(image_path)[-1].split(".")[1])` 接下来的一个循环`for (x, y, w, h) in faces`则是将图片和id都添加在list中。 再return一下即可。 ### 训练 差不多完成了,现在我们调用一下这个函数,然后将我们的数据喂给识别器去训练吧。 ``` faces, Ids = get_images_and_labels('dataSet') recognizer.train(faces, np.array(Ids)) recognizer.save('trainner/trainner.yml') ``` 现在只要我们运行这些代码,程序就会在trainner文件夹中创建一个trainner.yml文件。 ![](https://source.mkshell.com/15265428780936.jpg) 这个yml文件,存着我们的训练好的数据,以后识别会用到的。 ### 完整代码 ``` import cv2 import os import numpy as np from PIL import Image # recognizer = cv2.createLBPHFaceRecognizer() detector = cv2.CascadeClassifier("haarcascade_frontalface_default.xml") recognizer = cv2.face.LBPHFaceRecognizer_create() def get_images_and_labels(path): image_paths = [os.path.join(path, f) for f in os.listdir(path)] face_samples = [] ids = [] for image_path in image_paths: image = Image.open(image_path).convert('L') image_np = np.array(image, 'uint8') if os.path.split(image_path)[-1].split(".")[-1] != 'jpg': continue image_id = int(os.path.split(image_path)[-1].split(".")[1]) faces = detector.detectMultiScale(image_np) for (x, y, w, h) in faces: face_samples.append(image_np[y:y + h, x:x + w]) ids.append(image_id) return face_samples, ids faces, Ids = get_images_and_labels('dataSet') recognizer.train(faces, np.array(Ids)) recognizer.save('trainner/trainner.yml') ``` _先这样吧_ [原文](https://thecodacus.com/face-recognition-opencv-train-recognizer/),若有错误之处请指出,更多地关注[煎鱼](http://www.jianyujianyu.com)。 https://www.mkshell.com/posts/2018-05-16-raspberry-pi-3bplus-64-ubuntu-caffe/ https://www.mkshell.com/posts/2018-05-16-raspberry-pi-3bplus-64-ubuntu-caffe.md 2018-05-16 # 树莓派3B+ 64位Ubuntu安装caffe **_树莓派3B+到货了,当然要把玩一番。_** ## 背景 树莓派从3代开始就用上了64位的处理器,奈何却没有更新上64位的系统。据说用上64位的系统,能提升不少的性能,甚至有人用[RPi3B测试最快能提高30倍](http://www.52pi.net/archives/307)。 拿着RPi3B+的我就对这个更感兴趣了,先折腾一下64位系统,再装个caffe吧。 我知道一个大大搞了个[Ubuntu64位系统](https://github.com/chainsx/ubuntu64-rpi)。 虽然说64位系统提升很大,但是由于缺乏官方的支持,民间高手研究出来的64位系统可能也略欠火候。 比如说,没有针对于3B+完美的稳定的Ubuntu64位系统。 由于缺乏官方网卡的驱动的支持,用大大的64位系统甚至还不能使用官方网卡,我只能掏出我的USB网卡。至于怎么配置wifi就不说了。 再比如说,尽管大大的系统很牛逼了,但是Ubuntu16.04系统依然没法在RPi3B+上用,而Ubuntu18.04可以。 ## 安装 ### 依赖包 首先先把这一大堆东西装了吧,当然,你一次性全部装完也行都一个样。 apt自带安装的python是2.7.15的,我觉得随意吧还行。 ``` sudo apt update sudo apt install -y gfortran cython sudo apt install -y libprotobuf-dev libleveldb-dev libsnappy-dev libopencv-dev libhdf5-serial-dev protobuf-compiler git sudo apt install --no-install-recommends libboost-all-dev sudo apt install -y python-dev libgflags-dev libgoogle-glog-dev liblmdb-dev libatlas-base-dev python-skimage sudo apt install -y python-pip sudo apt install -y build-essential sudo apt install -y cmake libgtk2.0-dev pkg-config libavcodec-dev libavformat-dev libswscale-dev sudo apt install -y python-numpy libtbb2 libtbb-dev libjpeg-dev libpng-dev libtiff-dev libdc1394-22-dev ``` ### 建立SWAP 安装后我发现该系统没有自带SWAP,SWAP分区对于1G小内存的树莓派来说,是至关重要的。 我提前放个图,这个图是我在编译的时候截的,表示内存使用最严重的时候,用了900+的Memory和800+的SWAP。 ![](https://source.mkshell.com/15264723720807.jpg) 如果没有SWAP,那么编译就可能不会成功,因为会被杀掉。 首先我们在系统中划分1G空间来做SWAP,我用的位置的`/var/swap`: ``` sudo dd if=/dev/zero of=/var/swap bs=1M count=1024 sudo mkswap /var/swap sudo swapon /var/swap ``` 如果以后还想开机自动挂载SWAP的话,可以这样: ``` sudo vim /etc/fstab # 添加 /var/swap swap swap defaults 0 0 ``` 如下图: ![](https://source.mkshell.com/15264559134642.jpg) ### 安装OpenCV OpenCV还是必须要装的,这里我选一个2.4的OpenCV。 少废话,安pip包。 ``` pip install pyzmq jsonschema pillow pip install numpy scipy pip install ipython jupyter pyyaml ``` 其中numpy和scipy的编译难度可能较大,难在时间长、所需内存大造成SWAP换入换出严重阻塞IO。如果实在不行,可以使用apt之类的方法安装。 然后是下载OpenCV的包,并解压: ``` # 不喜欢的话,可以用aria2c wget https://github.com/opencv/opencv/archive/2.4.13.6.zip sudo apt install unzip unzip -o -d . 2.4.13.6.zip ``` 开始编译OpenCV,这个是十分耗时的,一般是几个小时。 对于树莓派3B+,编译OpenCV的主要问题有两个: 1. 内存不够,不够SWAP加了 2. 太热,自备小风扇或水冷谢谢(水冷真的不是开玩笑。。) ``` cd opencv-2.4.13.6 mkdir release cd release cmake -D CMAKE_BUILD_TYPE=RELEASE -D CMAKE_INSTALL_PREFIX=/usr/local .. make -j4 sudo make install sudo apt install python-opencv ``` 接下来我们检验一下Python中能不能正常导入OpenCV: ![](https://source.mkshell.com/15264729064951.jpg) 导入正常。 ### 编译Caffe 先把Caffe拉下来: ``` cd git clone https://github.com/BVLC/caffe cd caffe cp Makefile.config.example Makefile.config vim Makefile.config ``` 需要修改Makefile.config包括这几行: ``` # CPU_ONLY := 1 INCLUDE_DIRS := $(PYTHON_INCLUDE) /usr/local/include LIBRARY_DIRS := $(PYTHON_LIB) /usr/local/lib /usr/lib ``` 改成: ``` CPU_ONLY := 1 INCLUDE_DIRS := $(PYTHON_INCLUDE) /usr/local/include /usr/include/hdf5/serial/ LIBRARY_DIRS := $(PYTHON_LIB) /usr/local/lib /usr/lib /usr/lib/aarch64-linux-gnu/hdf5/serial/ ``` P.S. 修改这3行的意思是: 1. 修改为CPU模式,RPi没有GPU 2. 添加hdf5的include 3. 添加hdf5的lib,其中`aarch64-linux-gnu`是因为我用的是64位系统,如果是32位系统,可能是`arm-linux-gnueabihf`。 #### 开始编译 ``` make all -j4 make test -j4 make runtest -j4 ``` 然后就是漫长的等待,结束后加上也把Python的给编译了 ``` cd python pip install -r requirements.txt make pycaffe -j4 ``` 然后在zsh或者bash环境中添加pycaffe的环境: ``` export PYTHONPATH=/home/ubuntu/extra/caffe/python:$PYTHONPATH ``` #### 编译Caffe可能出现的错误: ##### 可能错误1:`undefined reference to cv::imread(cv::String const&, int)` 错误详情: ``` CXX/LD -o .build_release/tools/convert_imageset.bin .build_release/lib/libcaffe.so: undefined reference to cv::imread(cv::String const&, int)’ .build_release/lib/libcaffe.so: undefined reference tocv::imencode(cv::String const&, cv::_InputArray const&, std::vector >&, std::vector > const&)’ .build_release/lib/libcaffe.so: undefined reference to `cv::imdecode(cv::_InputArray const&, int)’ collect2: error: ld returned 1 exit status make: * [.build_release/tools/convert_imageset.bin] Error 1 ``` 方法: 修改Makefile ``` LIBRARIES += glog gflags protobuf leveldb snappy lmdb boost_system hdf5_hl hdf5 m ``` 改成 ``` LIBRARIES += glog gflags protobuf leveldb snappy lmdb boost_system hdf5_hl hdf5 m opencv_core opencv_highgui opencv_imgproc opencv_imgcodecs ``` ##### 可能错误2:`cannot find -lopencv_***` 错误详情: ``` LD -o .build_release/lib/libcaffe.so.1.0.0 /usr/bin/aarch64-linux-gnu-ld: cannot find -lopencv_imgcodecs collect2: error: ld returned 1 exit stauts Makefile:572: recipe for target '.build_release/lib/libcaffe.so.1.0.0' failed make: *** [.build_release/lib/libcaffe.so.1.0.0] Error 1 ``` 方法: ``` sudo vim /etc/ld.so.conf.d/opencv.conf # 添加 /usr/local/lib ``` _先这样吧_ 若有错误之处请指出,更多地关注[煎鱼](http://www.jianyujianyu.com)。 https://www.mkshell.com/posts/2018-05-13-face-recognition-dataset-generator/ https://www.mkshell.com/posts/2018-05-13-face-recognition-dataset-generator.md 2018-05-13 # Python-OpenCV人脸识别之数据集生成 在[上一篇文章](https://www.jianyujianyu.com/face-detection-using-opencv-in-python/)中,我们学习了如何安装配置OpenCV和Python,然后写了些代码玩玩人脸检测。现在我们要进行下一步了,即搞一个人脸识别程序,就是不只是检测还需要识别到人是谁。 ## 来,搞人脸识别 要搞一个人脸识别程序,首先我们需要先用提前裁剪好的标注好的人脸照片训练一个识别器。比如说,我们的识别器需要识别两个人,一个人的id是1,而另一个的id是2,于是在数据集里面,1号人的所有照片会有id 1号,2号人同理。然后我们就会使用这些数据集照片去训练识别器,再从一个视频中识别出1号人。 我们把要做的事分成三部分: 1. 创建数据集 2. 训练 3. 识别 在本文中,我们会尝试写一个程序来生成数据集。 ## 生成数据集 我们来写一个数据集生成脚本。 首先打开我们的Python环境,不管是Pycharm等IDE,还是简单的记事本都行。需要提前准备的是在目录中放好`haarcascade_frontalface_default.xml`,上一篇也有用到过这个XML文件,就是OpenCV自带的。 接下来使用cv2获取摄像头数据以及XML文件: ``` import cv2 cam = cv2.VideoCapture(0) detector=cv2.CascadeClassifier('haarcascade_frontalface_default.xml') ``` 我们的数据集需要先从摄像头采集一些人脸例子照片,当然,只能是同一个人的。然后程序会给这些例子照片添加id,并将照片保存在一个文件夹中,这个文件夹我们就将它命名为dataSet吧。 来,我们在py脚本的同目录下创建一个dataSet的文件夹。为了不会将不同的人脸照片弄混,我们需要定一个命名规则,用于给照片命名。 例如,命名规则为`User.[ID].[SampleNumber].jpg`。如果是2号人的第十张照片,我们可以将它命名为`User.2.10.jpg`。 为什么要定义这样的格式呢?因为这样,在加载照片训练的时候,我们就可以只通过照片的文件名,就能简单地判断是几号用户的人脸照片。 接下来,我们尝试用比较简单的方法,通过shell输入,来获取人的id,并且初始化计算器变量来存储人们的例子数。 ``` Id = raw_input('enter your id: ') sampleNum = 0 ``` 然后我们加入一个主循环,我们会从视频流中输入20个例子,然后把例子都保存在已经创建好的dataSet文件夹。 这是之前写过的代码版本,用于人脸检测: ``` while True: ret, img = cap.read() gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) faces = detector.detectMultiScale(gray, 1.3, 5) for (x, y, w, h) in faces: cv2.rectangle(img, (x, y), (x + w, y + h), (255, 0, 0), 2) cv2.imshow('frame', img) if cv2.waitKey(1) & 0xFF == ord('q'): break ``` 现在我们将它改造成数据集生成程序: ``` while True: ret, img = cap.read() gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) faces = detector.detectMultiScale(gray, 1.3, 5) for (x, y, w, h) in faces: cv2.rectangle(img, (x, y), (x + w, y + h), (255, 0, 0), 2) # 增加例子数 sampleNum = sampleNum + 1 # 把照片保存到数据集文件夹 cv2.imwrite("dataSet/user." + str(Id) + '.' + str(sampleNum) + ".jpg", gray[y:y + h, x:x + w]) cv2.imshow('frame', img) if cv2.waitKey(1) & 0xFF == ord('q'): break ``` 我们添加了两行代码,用以计算例子数,以及将人脸照片按照我们的命名规则保存为jpg格式。 其中有一个值得注意的地方,就是`gray[y : y + h, x : x + w]`。此处我们是把一张灰度图片看成一个二维数组(或二维矢量),然后使用python中`[]`截取OpenCV检测出来的人脸区域。 不过这样的代码会在一秒内快速地生成许多照片,比如说20张。我们不想要那么快,我们需要的是更好的素材,比如说从不同角度拍摄出来的照片,这样的话,要求慢一点。 为了慢一点,我们需要提高一下两次拍摄之间的延迟。同时,我们素材不需要太多,20张就好。 ``` while True: ret, img = cap.read() gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) faces = detector.detectMultiScale(gray, 1.3, 5) for (x, y, w, h) in faces: cv2.rectangle(img, (x, y), (x + w, y + h), (255, 0, 0), 2) # 增加例子数 sampleNum = sampleNum + 1 # 把照片保存到数据集文件夹 cv2.imwrite("dataSet/User." + str(Id) + '.' + str(sampleNum) + ".jpg", gray[y:y + h, x:x + w]) # cv2.imshow('frame', img) # 延迟100毫秒 if cv2.waitKey(100) & 0xFF == ord('q'): break # 超过20张就可以停了 elif sampleNum > 20: break ``` 好,继续,现在的代码就会在两个拍摄间延迟100毫秒,100毫秒足够让我们去移动我们人脸的角度了(时间不够长就再加)。而且,在拍摄20张后就停止了。 最后记得释放资源: ``` cap.release() cv2.destroyAllWindows() ``` 放出完整代码: ``` import cv2 detector = cv2.CascadeClassifier('haarcascade_frontalface_default.xml') cap = cv2.VideoCapture(0) sampleNum = 0 Id = raw_input('enter your id: ') while True: ret, img = cap.read() gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) faces = detector.detectMultiScale(gray, 1.3, 5) for (x, y, w, h) in faces: cv2.rectangle(img, (x, y), (x + w, y + h), (255, 0, 0), 2) # incrementing sample number sampleNum = sampleNum + 1 # saving the captured face in the dataset folder cv2.imwrite("dataSet/User." + str(Id) + '.' + str(sampleNum) + ".jpg", gray[y:y + h, x:x + w]) # cv2.imshow('frame', img) # wait for 100 miliseconds if cv2.waitKey(100) & 0xFF == ord('q'): break # break if the sample number is morethan 20 elif sampleNum > 20: break cap.release() cv2.destroyAllWindows() ``` ## 生成结果 ![](https://source.mkshell.com/15262303148182.jpg) 如图,已经生成了一堆训练素材了。 _先这样吧_ [原文](https://thecodacus.com/opencv-face-recognition-python-part1/#.WvbuitOWT5s),若有错误之处请指出,更多地关注[煎鱼](http://www.jianyujianyu.com)。 https://www.mkshell.com/posts/2018-05-12-face-detection-using-opencv-in-python/ https://www.mkshell.com/posts/2018-05-12-face-detection-using-opencv-in-python.md 2018-05-12 # 在Python中使用OpenCV进行人脸检测 **_OpenCV是如今最流行的计算机视觉库,而我们今天就是要学习如何安装使用OpenCV,以及如何去访问我们的摄像头。然后我们一起来看看写一个人脸检测程序是如何地简单,简单到只需要几行代码。_** 在开始之前,我假设你已经对Python有一定的了解。当然,如果你觉得你还不够格,这里有推荐一些[学习Python的电子书](https://amzn.to/2kUK9WQ),你可以先学习下Python,如此可以让你更好地理解接下来的步骤。另外,这里还推荐一本[电子书](https://amzn.to/2mtfCf1)来学习OpenCV。 好,不浪费时间,开始吧。 首先我们需要先准备好这些环境(版本记得配好): - Python 2.x - OpenCV 2.x - Numpy库 (这个可以在稍后用pip下载) 首先,对于下载Python,我们可以先到[官网](https://www.python.org/downloads/)上下对应的版本,如果是Windows就可能是msi格式的版本,如果是Mac就可能是pkg格式的安装包,如果是Linux则可能是源码包。 安装和Python后打开命令行就可以使用pip命令进行Python包的安装了,如: ![](https://source.mkshell.com/15261274043025.jpg) 由于OpenCV使用Numpy库,因此先通过命令`pip install numpy`安装Numpy库。安装完后,尝试导入,没有报错则ok: ![](https://source.mkshell.com/15261275174355.jpg) 然后进入[OpenCV官网](https://opencv.org/releases.html)下对应的版本并安装,尝试导入: ![](https://source.mkshell.com/15261276882488.jpg) ## 尝试人脸检测 万事俱备,只欠东风。我们来写代码检测人脸吧,来一发OpenCV的Hello world。 在这里我们准备使用pre-trained的XML文件,这些XML文件都较难训练,但是我们不需要担心,因此OpenCV已经为我们提供了很多人脸检测相关的pre-trained分类器。 想要使用这写分类器,我们需要将分类器的XML文件`haarcascade_frontalface_default.xml`从`opencv文件夹/sources/data/haarcascades/`下复制到我们的项目目录下,就是我们将要写程序的目录下。如果没有`opencv文件夹/sources/data/haarcascades/`这个目录,可以尝试找一下`opencv文件夹/share/OpenCV/haarcascades/`。只要找到如下文件即可: ![](https://source.mkshell.com/15261284236153.jpg) 然后如果我们要加载这个分类器的话,如此就好: ``` detector = cv2.CascadeClassifier('haarcascade_frontalface_default.xml') ``` 然后接下来我们先来测试一下摄像头吧, ``` cap = cv2.VideoCapture(0) ret, img = cap.read() cv2.imshow('windowname', img) cv2.waitKey(0) # 释放摄像头资源 cap.release() ``` 以上的代码是调用你电脑的0号摄像头,并展示出来。当然,如果你有多个摄像头,那么你也可以试试别的id,修改VideoCapture函数的参数即可。 其中cap.read()就是从摄像头获取到图像,这个函数返回了两个变量,第一个为布尔值表示成功与否,以及第二个是图像。 然后程序通过imshow()展示图片,其第一个传入的参数为窗口的名称,而第二个就是要展示的图片,以上代码传入的就是我们的自拍。 而waitKey是用来停在图片的展示界面,让你看清楚,参数可以是10、100、1000等,单位是毫秒,这里填0就是一直停着。注意了,如果停留的时间不够久,就可能看不见imshow的照片了。 运行了这段代码,你将会看到摄像头所拍摄的画面,一般来说就是你自己了。 ![](https://source.mkshell.com/15261313899799.jpg) 接下来我们将图片先转换为灰度图片, ``` gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) ``` 然后就开始了人脸检测之旅: ``` faces = detector.detectMultiScale(gray, 1.3, 5) ``` 以上的这句代码会等到一串list,list中的每个都有x, y, height, width四个变量。其中list表示检测到的人脸,即list的size就是人脸的个数,而每个人脸在图片中的位置是(x, y, height, width)。 为了能让我们更直观地看出来检测结果,我们将这些人脸框出来: ``` for (x, y, w, h) in faces: cv2.rectangle(img, (x, y), (x + w, y + h), (255, 0, 0), 2) ``` ![](https://source.mkshell.com/15261316384652.jpg) ## 更进一步 现在我们已经通过摄像头检测到人脸了,但是我们真正需要的是不是一张静态的图片,我们需要的是一个能检测的实时动态视频流。因此我们加一个循环,然后不断的检测,最终在显示在新窗口中。 ``` detector = cv2.CascadeClassifier('haarcascade_frontalface_default.xml') cap = cv2.VideoCapture(0) while True: ret, img = cap.read() gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) faces = detector.detectMultiScale(gray, 1.3, 5) for (x, y, w, h) in faces: cv2.rectangle(img, (x, y), (x + w, y + h), (255, 0, 0), 2) cv2.imshow('frame', img) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows() ``` 需要注意的是,结尾用了waitKey和ord实现了按q退出的功能,就是每一毫秒都在检测键盘有没有按下了q,要是按下了就退出循环了。接下来就释放资源。 ## 总结 在本文中,我们学习了如何使用Python中的OpenCV,即通过代码写了一个人脸检测的程序。我们温习或学习了这些知识点: - 使用OpenCV的分类器 - 从摄像头中读取照片 - 在图片上换框框 - 在新窗口上展示图片 - 实时地进行人脸检测 **_P.S. 你们发现了XML那里有一个叫猫脸识别的文件吗!!!_** _先这样吧_ [原文](https://thecodacus.com/opencv-python-face-detection/#.WvbdLtOWT5t),若有错误之处请指出,更多地关注[煎鱼](http://www.jianyujianyu.com)。 https://www.mkshell.com/posts/2018-05-08-php-fpm-fastcgi-relation/ https://www.mkshell.com/posts/2018-05-08-php-fpm-fastcgi-relation.md 2018-05-08 # PHP-FPM与FastCGI的关系 **_突然发现整理了很多笔记,应该放这里做备用_** ## CGI CGI,是一个规范,是为了保证web server传递过来的数据是标准格式的,方便CGI程序的编写者。 如果请求`/index.html`,web server会去文件系统中找到这个文件,发送给浏览器。如果请求的是`/index.php`,按照nginx配置,请求回连同数据给**PHP解析器**来处理,CGI就是规定要传哪些数据、以什么样的格式传递给后方处理这个请求的协议。 当web server收到`/index.php`这个请求后,会启动对应的CGI程序,这里就是PHP的解析器。接下来PHP解析器会解析php.ini文件,初始化执行环境,然后处理请求,再以规定CGI规定的格式返回处理后的结果,退出进程。web server再把结果返回给浏览器。 ## FastCGI FastCGI,是一个协议,是用来提高CGI程序性能的。 标准的CGI实现对每个请求都会执行这些步骤: 1. PHP解析器会解析php.ini文件 2. 初始化执行环境 如果每次都需要执行这些操作,则将会多出很多重复多余的操作。如php的配置文件段时间内是不会改变的,那么在这段不改变的时间内,要是有一万个请求,那么环境就要初始一万次。这对于计算资源和时间资源来说,是一种浪费。 对此,FastCGI会如此进行优化: 1. 首先,Fastcgi会先启一个master,解析配置文件,初始化执行环境 2. 然后再启动多个worker 3. 当请求过来时,master会传递给一个worker,然后立即可以接受下一个请求。 FASTCGI是常驻型的CGI,它可以一直运行,在请求到达时,不会花费时间去fork一个进程来处理。 ## PHP-CGI PHP的解释器是PHP-CGI。PHP-CGI只是个CGI程序,他自己本身只能解析请求,返回结果。 当每次接到HTTP前端服务器的请求都会开启一个PHP-CGI进程进行处理,而且开启的php-cgi的过程中会先要重载配置,数据结构以及初始化运行环境。 简单来说,PHP-CGI就是实现了CGI,拥有了CGI的慢的缺点。 ## PHP-FPM PHP-FPM是一个实现了FastCGI的程序,被PHP官方收了。 PHP-FPM可以说的是对PHP-CGI的改进,改进在于它吸取了FastCGI的优点。PHP-FPM会开启多个PHP-CGI程序,并且PHP-FPM常驻内存,因为PHP-FPM是管理PHP-CGI的进程。 _先这样吧_ 若有错误之处请指出,更多地关注[煎鱼](http://www.jianyujianyu.com)。 https://www.mkshell.com/posts/2018-05-07-hashmap-hashset-hashtable-difference/ https://www.mkshell.com/posts/2018-05-07-hashmap-hashset-hashtable-difference.md 2018-05-07 # HashMap、HashSet、Hashtable的区别 **_突然发现整理了很多笔记,应该放这里做备用_** ## Hashtable和HashMap **主要区别:线程安全性,同步(synchronization),以及速度。** HashMap几乎可以等价于Hashtable,除了HashMap是非synchronized的,并可以接受null。Hashtable是线程安全的,多个线程可以共享一个Hashtable。 HashMap的同步问题可通过Collections的一个静态方法得到解决,`Map Collections.synchronizedMap(Map m)` 返回一个同步的Map。 HashMap的迭代器(Iterator)是fail-fast迭代器,而Hashtable的enumerator迭代器不是fail-fast的。fail-fast结构上更改时(删除或者插入一个元素),将会抛出ConcurrentModificationException异常。 HashMap不能保证随着时间的推移Map中的元素次序是不变的。 ## HashSet和HashMap的区别 HashSet实现了Set接口,它不允许集合中有重复的值,当我们提到HashSet时,第一件事情就是在将对象存储在HashSet之前,要**先确保对象重写equals()和hashCode()方法**,这样才能比较对象的值是否相等,以确保set中没有储存相等的对象。如果我们没有重写这两个方法,将会使用这个方法的默认实现。 Map中不允许重复的键。Map接口有两个基本的实现,HashMap和TreeMap。TreeMap保存了对象的排列次序,而HashMap则不能。HashMap允许键和值为null。 HashSet 和 HashMap 之间有很多相似之处,对于 HashSet 而言,系统采用 Hash 算法决定集合元素的存储位置,这样可以保证能快速存、取集合元素;对于 HashMap 而言,系统 key-value 当成一个整体进行处理,系统总是根据 Hash 算法来计算 key-value 的存储位置,这样可以保证能快速存、取 Map 的 key-value 对。 ## HashMap ```java public V put(K key, V value) { // 如果 key 为 null,调用 putForNullKey 方法进行处理 if (key == null) return putForNullKey(value); // 根据 key 的 keyCode 计算 Hash 值 int hash = hash(key.hashCode()); // 搜索指定 hash 值在对应 table 中的索引 int i = indexFor(hash, table.length); // 如果 i 索引处的 Entry 不为 null,通过循环不断遍历 e 元素的下一个元素 for (Entry e = table[i]; e != null; e = e.next) { Object k; // 找到指定 key 与需要放入的 key 相等(hash 值相同 // 通过 equals 比较放回 true) if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } // 如果 i 索引处的 Entry 为 null,表明此处还没有 Entry modCount++; // 将 key、value 添加到 i 索引处 addEntry(hash, key, value, i); return null; } ``` Map.Entry,每个 Map.Entry 其实就是一个 key-value 对。 当系统决定存储 HashMap 中的 key-value 对时,完全没有考虑 Entry 中的 value,仅仅只是根据 key 来计算并决定每个 Entry 的存储位置。可以把 Map 集合中的 value 当成 key 的附属。 indexFor(int h, int length) 方法来计算该对象应该保存在 table 数组的哪个索引处。 根据上面 put 方法的源代码可以看出,当程序试图将一个 key-value 对放入 HashMap 中时,程序首先根据该 key 的 hashCode() 返回值决定该 Entry 的存储位置:如果两个 Entry 的 key 的 hashCode() 返回值相同,那它们的存储位置相同。如果这两个 Entry 的 key 通过 equals 比较返回 true,新添加 Entry 的 value 将覆盖集合中原有 Entry 的 value,但 key 不会覆盖。如果这两个 Entry 的 key 通过 equals 比较返回 false,新添加的 Entry 将与集合中原有 Entry 形成 Entry 链,而且新添加的 Entry 位于 Entry 链的头部。 Map提供了一些常用方法,如keySet()、entrySet()等方法,keySet()方法返回值是Map中key值的集合;entrySet()的返回值也是返回一个Set集合,此集合的类型为Map.Entry,接口中有getKey()、getValue方法。 ### 内部实现 HashMap实际上是一个“链表散列”的数据结构,即数组和链表的结构,但是在jdk1.8里 ,加入了红黑树的实现,当链表的长度大于8时,转换为红黑树的结构。 ![](https://source.mkshell.com/15256749922452.jpg) 少于8个的时候,Java中HashMap采用了链地址法。 通过什么方式来控制map使得Hash碰撞的概率又小,哈希桶数组(Node\[\] table)占用空间又少呢?答案就是好的Hash算法和扩容机制。即使负载因子和Hash算法设计的再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响HashMap的性能。 而当链表长度太长(默认超过8)时,链表就转换为红黑树,利用红黑树快速增删改查的特点提高HashMap的性能。 indexFor方法一般想到的是把hash值对数组长度取模运算,但运算较大,因此1.7中使用以下方法,&比%具有更高的效率: ```java static int indexFor(int h, int length) { return h & (length-1); //第三步 取模运算 } ``` 在JDK1.8的实现中,优化了高位运算的算法:`(h = k.hashCode()) ^ (h >>> 16)` ### 红黑树 红黑树(Red Black Tree) 是一种自平衡二叉查找树。红黑树和AVL树一样都对插入时间、删除时间和查找时间提供了最好可能的最坏情况担保。除了O(log n)的时间之外,红黑树的持久版本对每次插入或删除需要O(log n)的空间。 ## equals()和hashCode() 两个obj,如果equals()相等,hashCode()一定相等。 两个obj,如果hashCode()相等,equals()不一定相等(Hash散列值有冲突的情况,虽然概率很低)。 equals()和hashcode()这两个方法都是从object类中继承过来的。equals()是对两个对象的地址值进行的比较(即比较引用是否相同),hashCode()是一个本地方法,它的实现是根据本地机器相关的。 ### hashCode()在new的用途 JVM每new一个Object,它都会将这个Object丢到一个Hash哈希表中去,这样的话,下次做Object的比较或者取这个对象的时候,它会根据对象的hashcode再从Hash表中取这个对象。这样做的目的是提高取对象的效率。 如果不同的对象确产生了相同的hash值,也就是发生了Hash key相同导致冲突的情况,那么就在这个Hash key的地方产生一个链表,将所有产生相同hashcode的对象放到这个单链表上去,串在一起。比较两个对象的时候,首先根据他们的hashcode去hash表中找他的对象,当两个对象的hashcode相同,只能根据Object的equal方法来比较这个对象是否equal。 ### 重写equals()的原因 Object的equal方法默认是两个对象的引用的比较,意思就是指向同一内存。 但是,String对象中equals方法是判断值的,而==是地址判断(因为JDK重写了)。 我们很大部分时间都是进行两个对象的比较(而不是比较引用),这个时候Object的equals()方法就不可以了,所以才会有String这些类对equals方法的改写,依次类推Double、Integer、Math等等这些类都是重写了equals()方法的,从而进行的是内容的比较。 ### 重写equals()后要重写hashCode()的理由 java.lnag.Object中对hashCode的约定: 1. 在一个应用程序执行期间,如果一个对象的equals方法做比较所用到的信息没有被修改的话,则对该对象调用hashCode方法多次,它必须始终如一地返回同一个整数。 2. 如果两个对象根据equals(Object o)方法是相等的,则调用这两个对象中任一对象的hashCode方法必须产生相同的整数结果。 3. 如果两个对象根据equals(Object o)方法是不相等的,则调用这两个对象中任一个对象的hashCode方法,不要求产生不同的整数结果。但如果能不同,则可能提高散列表的性能。 只要改写了就会违约,所以要继续改写。 ## Hashtable与ConcurrentHashMap区别 ConcurrentHashMap融合了hashtable和hashmap二者的优势。hashmap在单线程情况下效率较高;hashtable在的多线程情况下,同步操作能保证程序执行的正确性。 hashtable每次同步执行的时候都要锁住整个结构: ![](https://source.mkshell.com/15256750113128.jpg) ConcurrentHashMap锁的方式是稍微细粒度的。 更令人惊讶的是ConcurrentHashMap的读取并发,因为在读取的大多数时候都没有用到锁定,所以读取操作几乎是完全的并发操作,而写操作锁定的粒度又非常细,比起之前又更加快速(这一点在桶更多时表现得更明显些)。**只有在求size等操作时才需要锁定整个表。** 在迭代时,使用**弱一致迭代器**,不再是抛出 ConcurrentModificationException,在改变时new新的数据从而不影响原有的数据,iterator完成后再将头指针替换为新的数据。 ## 常见问题 ### HashMap的工作原理,HashMap的get()方法的工作原理 答:“HashMap是基于hashing的原理,我们使用put(key, value)存储对象到HashMap中,使用get(key)从HashMap中获取对象。当我们给put()方法传递键和值时,我们先对键调用hashCode()方法,返回的hashCode用于找到bucket位置来储存Entry对象。” 关键:HashMap是在bucket中储存键对象和值对象,作为Map.Entry。 ### 当两个对象的hashcode相同会发生什么 因为hashcode相同,所以它们的bucket位置相同,发生冲突,Entry(包含有键值对的Map.Entry对象)会存储在链表中。 ### hashcode相同如何获取值对象 遍历链表直到找到Entry对象,找到bucket位置之后,会调用keys.equals()方法去找到链表中正确的节点。 ### 超过了负载因子(load factor)定义的容量 默认的负载因子大小为0.75,将会创建原来HashMap大小的两倍的bucket数组,来重新调整map的大小(rehashing)。当多线程的情况下,可能产生条件竞争(race condition),两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。 _先这样吧_ 若有错误之处请指出,更多地关注[煎鱼](http://www.jianyujianyu.com)。 https://www.mkshell.com/posts/2018-05-05-java-memory-model/ https://www.mkshell.com/posts/2018-05-05-java-memory-model.md 2018-05-05 # Java 内存模型笔记 _之前为了面试,整理过一堆笔记_ JMM 定义了 Java 虚拟机 (JVM) 在计算机内存 (RAM) 中的工作方式。JVM 是整个计算机虚拟模型,所以 JMM 是隶属于 JVM 的。 ## 并发编程 两个关键问题:线程之间的通信和同步 ### 线程之间的通信 在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写 - 读内存中的公共状态来隐式进行通信,典型的共享内存通信方式就是**通过共享对象进行通信**。 在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信,**在 java 中典型的消息传递方式就是 wait() 和 notify()**。 ### 线程之间的同步 同步是指程序用于控制不同线程之间操作发生相对顺序的机制。 在共享内存并发模型里,同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。 在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。 ### Java 的并发采用的是共享内存模型 Java 线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。 ## Java 内存模型 JMM 决定一个线程对共享变量的写入何时对另一个线程可见。JMM 定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读 / 写共享变量的副本。 ![](https://source.mkshell.com/15255233928656.jpg) 1. 首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去。 2. 然后,线程B到主内存中去读取线程A之前已更新过的共享变量。 **通信过程必须要经过主内存。** 当对象和变量存储到计算机的各个内存区域时,最主要的两个问题是: 1. 共享对象对各个线程的可见性 2. 共享对象的竞争现象 ### 可见性 如果没有合理的使用 volatile 和 synchronization 关键字,一个线程对共享对象的更新有可能导致其它线程不可见。 ![](https://oda3wj69k.qnssl.com/15255234113759.jpg) **volatile 关键字可以保证变量会直接从主存读取,而对变量的更新也会直接写到主存。** ### 竞争现象 如果多个线程共享一个对象,如果它们同时修改这个共享对象,这就产生了竞争现象。 可以使用 java synchronized 代码块。**synchronized 代码块可以保证同一个时刻只能有一个线程进入代码竞争区**,synchronized 代码块也能保证代码块中所有变量都将会从主存中读,当线程退出代码块时,对所有变量的更新将会 flush 到主存,不管这些变量是不是 volatile 类型的。 ## 支撑 Java 内存模型的基础原理 ### 指令重排序 - **编译器优化重排序:**编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。 - **指令级并行的重排序:**如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。 - **内存系统的重排序:**处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。 不管怎么重排序,单线程下的执行结果不能被改变,编译器、runtime 和处理器都必须遵守 **as-if-serial** 语义。 ### 内存屏障(Memory Barrier ) - 保证特定操作的执行顺序。 - 影响某些数据(或则是某条指令的执行结果)的内存可见性。 插入一条 Memory Barrier 会告诉编译器和 CPU:不管什么指令都不能和这条 Memory Barrier 指令重排序。 Memory Barrier 所做的另外一件事是强制刷出各种 CPU cache,如一个Write-Barrier(写入屏障)将刷出所有在 Barrier 之前写入 cache 的数据,因此,任何 CPU 上的线程都能读取到这些数据的最新版本。(volatile 是基于 Memory Barrier 实现的。)volatile修饰时,JMM 会在写入这个字段之后插进一个Write-Barrier指令,并在读这个字段之前插入一个Read-Barrier指令 ### happens-before 在 JMM 中,如果一个操作的执行结果需要对另一个操作可见,那么这两个操作之间必须要存在 happens-before 关系,这个的两个操作既可以在同一个线程,也可以在不同的两个线程中。 happens-before 规则如下: - **程序顺序规则:**一个线程中的每个操作,happens-before 于该线程中任意的后续操作。 - **监视器锁规则:**对一个锁的解锁操作,happens-before 于随后对这个锁的加锁操作。 - **volatile 域规则:**对一个 volatile 域的写操作,happens-before 于任意线程后续对这个 volatile 域的读。 - **传递性规则:**如果 A happens-before B,且 B happens-before C,那么 A happens-before C。 #### 通俗解释 如果线程1解锁了monitor a,接着线程2锁定了a,那么,线程1解锁a之前的写操作都对线程2可见(线程1和线程2可以是同一个线程)。 如果线程1写入了volatile变量v(这里和后续的“变量”都指的是对象的字段、类字段和数组元素),接着线程2读取了v,那么,线程1写入v及之前的写操作都对线程2可见(线程1和线程2可以是同一个线程)。 ```java 1:a = 1; 2:list.set(1,"t"); // p1 < p2 ``` 线程t1写入的所有变量(所有action都与那个join有hb关系,当然也包括线程t1终止前的最后一个action了,最后一个action及之前的所有写入操作,所以是所有变量),在任意其它线程t2调用t1.join()成功返回后,都对t2可见。 线程中上一个动作及之前的所有写操作在该线程执行下一个动作时对该线程可见(也就是说,同一个线程中前面的所有写操作对后面的操作可见) _先这样吧_ 若有错误之处请指出,更多地关注[煎鱼](http://www.jianyujianyu.com)。 https://www.mkshell.com/posts/2018-05-05-replication-consistent-data-problems/ https://www.mkshell.com/posts/2018-05-05-replication-consistent-data-problems.md 2018-05-05 # Replication中常见的数据一致性的问题 ## 背景 简而言之,Replication是我们在部署分布式或集群中的一种模式,Replication可以了解为复制或冗余。 使用Replication的原因也很明显: 1. 可以在一定程度上,降低不同地区用户访问的延迟 2. 提供部分宕机情况下的可用服务 3. 提高服务的最高吞吐量 众所周知,Replication分有三种,Single-Leader、Multi-Leader以及Leader-Less。其中对于我来说,最常见的则是Single-Leader,即主从模式。 Single-Leader和Multi-Leader都是属于Leader-based,这类型的规则也很简单: 1. 用户的写入请求必须通过Leader,Leader接收到自身写入后再同步给Followers 2. 用户的读取请求则可以从所有节点中读取,不过一般只从Follower读。 Leader-based Replication分同步和异步,甚至还有位于中间的半同步。 同步意味着用户请求了写入Leader后,必须要等到Leader的数据同步到全部Follower后才将此次写入请求结果返回给用户。也就是说,同步需要等很长时间。 而异步则是用户请求了Leader后,Leader马上就返回了一个“假结果”,然后Leader再自己偷偷地将数据同步给各个Follower。这样的处理很快,但是在用户得到结果后却不能保证服务后续操作的可靠性。 当然还有中间的半同步模式,则是Leader接收到用户的请求后也先将数据同步给各个Follower,若是其中有那么一个Follower完成了这个同步操作,Leader就立刻返回结果给用户了。这种处理方法比同步快,比异步靠谱。 同步异步就到此吧。 为了享受Replication的优点,我们也必须要忍受它各种各样的缺点,如新增Follower的方法、部分节点宕机恢复后的数据恢复、脑裂情况下的处理方式、超时时间的调优。 幸运的是,问题先来,解决方法总会是陆续而来。当然以上的问题都不是本笔记的重点,本笔记的重点是谈谈数据的一致性。 ## 问题 ### 读取自己的写入数据(Reading your own writes) 某场景下,或者说某种接口中,用户提交了新增的数据或者更新了数据后,需要预览到自己刚才提交的数据。 如果在同步模式下,那么这个场景根本没有这个问题。然而这种做法简直是在牺牲用户体验来做较为完美的一致性,我不太认同这种trade-off。 我们还是谈谈异步模式下这个场景的问题。异步模式下,由于写入数据只到达了Leader,而Follower可能还没有同步。而如果这个时候用户读取了没有同步到的Follower,就会有一种疑惑:我刚才是否执行失败了。 而这里要做的保证就是,起码用户读取自己刚刚写入的数据是没有问题的。 这里需要引入写后读一致性(read-after-write consistency),是一种较弱的一致性。要实现这种一致性一般需要: 1. 当用户读取某一样可以会被修改的数据时,请从Leader读 2. 若大多数数据都可能会被修改,还遵循着1则会无限方法Leader的负担,这种情况下,我们可以先检测需要读取的数据是否最近被修改过(比如说一分钟内),再判断从哪里读 3. 当然客户端也可以自个保留这一个逻辑时间戳,在请求的时候就带上这个时间戳,好让服务端评断是否最近执行过更新操作 如此看来还是比较简单的。 如果场景迁移到多个Leader的多个数据仓库下的话,可能会复杂一点。再有就是同一个用户账号,在电脑端上更新了,然后短时间内在移动设备上读取,如此的跨设备写后读一致性也是需要考虑在内的。 ### 单调读(Monotonic Reads) 如果一个用户每次读取的Replica节点不一样,就有可能会产生一种现象,倒退(回退)。 比如说,一个叫煎鱼的用户读取了2号节点,看到了2018-05-06 00:05:19的新闻,然后下次刷新读取了3好节点,由于3号节点具有较大的网络延迟,新闻只同步到2018-05-06 00:05:00,就会给用户一个假象,05:00到05:19的新闻怎么不见了。 当然要保证单读也很简单,就是同一个用户只读取同一个机器节点。 常见的现实方式就是负载均衡,一般以地区划分用户的访问节点,或者使用hash。 ### 一致前缀读/一致顺序读(Consistent Prefix Reads) 存在一种情况,一个叫煎鱼的用户在不同的节点中读取了多条数据,由于时间延迟不一样,2号机器比3号机器慢,因此2号00:05:19的新闻显示在3号机器的00:05:40的新闻的后面,导致新闻的时间很不连贯。 当然,如果新闻的时间不连贯是没什么问题的,最严重的此类问题是发生在聊天场景中。如果聊天中各个节点的消息没有按照时间排序,就会给用户造成极大的消极影响。 因此,引入一致前缀读(个人觉得按照意思翻译成一致顺序读好了解点),意思就是在写入的时候是什么样的一个顺序,在读取的时候也要按照这个样的顺序来读取。 如,既然按照时间顺序来写入,就要按照时间顺序来解读。如果按照别的序号顺序写入,就要按照那个序号顺序来读取。 关键点就在于读取的客户端需要处理一下。 ## 问题是否必要 以上数据一致性问题,都是基于异步模式下的假设。这些一致性的保证并不是说非做不可,但是不做则将会对用户体验造成重大影响。 如果不关心用户体验的话,那么也没有必要采取异步模式了是吧。 _先这样吧_ 若有错误之处请指出,更多地关注[煎鱼](http://www.jianyujianyu.com)。 https://www.mkshell.com/posts/2018-05-04-hello-world/ https://www.mkshell.com/posts/2018-05-04-hello-world.md 2018-05-04 # 世界,您好! 好吧,就这样的, 因为懒,我的博客又从Hexo回到Wordpress了。 欢迎使用WordPress。这是您的第一篇文章。编辑或删除它,然后开始写作吧! https://www.mkshell.com/posts/2017-05-04-ubuntu16-04-hadoop2-8-0/ https://www.mkshell.com/posts/2017-05-04-ubuntu16-04-hadoop2-8-0.md 2017-05-04 # 在 Ubuntu16.04 上搭建 Hadoop-2.8.0 伪分布式环境 **_学校有几门课程的作业需要我们来写个教程,好吧。_** ## 环境准备 ### 软件安装 由于该系统是英文系统,有可能对中文字符支持不友好,因此要安装中文的UTF-8字符集。同时因为Hadoop是Java的杰出产物因此需要安装Java,为了方便,在这里选用OpenJdk,版本Java8。而需要用到的vim、ssh等命令已内置在Ubuntu16.04中,因此无需重复安装。 ``` sudo su apt install -y language-pack-zh-hant language-pack-zh-hans apt install -y openjdk-8-jre openjdk-8-jdk ``` ### 创建用户以及权限配置 本次实验中我们创建一个hadoop用户,而对hadoop的使用操作都通过hadoop用户实现。同时为了方便hadoop操作中的ssh登录,我们配置无密码ssh登录。 ``` useradd -m hadoop -s /bin/bash passwd hadoop adduser hadoop sudo su hadoop ``` 此后,hadoop用户便是运行hadoop程序的用户,以下的操作便是以hadoop用户的身份运行的。接下来生成密钥用于无密码登录: ``` ssh-keygen -t rsa cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys ``` ![](https://source.mkshell.com/2017-05-04-Screen%20Shot%202017-05-03%20at%2010.30.57%20PM.png) 配置完后即可通过`ssh localhost`命令无密码登录,需要注意的是要按提示输入yes ![Screen Shot 2017-05-03 at 10.31.13 P](https://source.mkshell.com/2017-05-04-Screen%20Shot%202017-05-03%20at%2010.31.13%20PM.png) ### 下载hadoop并放置到约定俗成的位置 这次实验的hadoop版本是2.8.0,下载使用wget命令,软件源为了快速选用国内的[清华源](http://mirrors.tuna.tsinghua.edu.cn '清华源'),同时将hadoop文件夹移到约定俗成的`/usr/local`目录下。 ``` wget http://mirrors.tuna.tsinghua.edu.cn/apache/hadoop/common/hadoop-2.8.0/hadoop-2.8.0.tar.gz tar zxvf hadoop-2.8.0.tar.gz ``` ![Screen Shot 2017-05-03 at 10.32.49 P](https://source.mkshell.com/2017-05-04-Screen%20Shot%202017-05-03%20at%2010.32.49%20PM.png) ``` mv hadoop-2.8.0 hadoop sudo mv hadoop /usr/local/ sudo chown -R hadoop /usr/local/hadoop cd /usr/local/hadoop/ ``` 此后,`/usr/local/hadoop`这个目录便是hadoop目录,下面的操作都是在目录下操作的。 ### 配置Java和Hadoop的环境变量 需要在`/etc/profile`和`./etc/hadoop/hadoop-env.sh`(目前在hadoop目录下)这两个文件的末尾加入以下变量: ``` export JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64 CLASSPATH=$CLASSPATH.:$JAVA_HOME/lib:$JAVA_HOME/jre/lib export PATH=$PATH:$JAVA_HOME/bin:$JAVA_HOME/jre/bin export HADOOP_HOME=/usr/local/hadoop export HADOOP_COMMON_LIB_NATIVE_DIR=$HADOOP_HOME/lib/native ``` 修改第一个文件就是修改当前的环境变量,修改第二个文件就是修改ssh后的环境变量。 这些就是Java和Hadoop的环境变量,加载了这些环境变量即可在命令行运行特定命令,现在重新加载这配置文件: ``` . /etc/profile ``` ## 配置伪分布式环境 ### 初始化hdfs 编辑./etc/hadoop/core-site.xml和./etc/hadoop/hdfs-site.xml两个文件 ``` vim ./etc/hadoop/core-site.xml ``` 修改里面配置,感兴趣的可以查一下含义,主要是每一个property的name和value: ``` hadoop.tmp.dir file:/usr/local/hadoop/tmp Abase for other temporary directories. fs.defaultFS hdfs://localhost:9000 ``` ``` vim ./etc/hadoop/hdfs-site.xml ``` 修改里面配置,指名name、data文件夹在这个服务器上的路径(因为是伪分布式,所以都在这个服务器上): ``` dfs.replication 1 dfs.namenode.name.dir file:/usr/local/hadoop/tmp/dfs/name dfs.datanode.data.dir file:/usr/local/hadoop/tmp/dfs/data ``` 以上就完成了伪分布式的配置,接下来就是格式化这些存储节点 ``` ./bin/hdfs namenode -format ``` ![Screen Shot 2017-05-03 at 11.20.51 P](https://source.mkshell.com/2017-05-04-Screen%20Shot%202017-05-03%20at%2011.20.51%20PM.png) 中间太多了略过直接看末尾: ![Screen Shot 2017-05-03 at 11.21.37 P](https://source.mkshell.com/2017-05-04-Screen%20Shot%202017-05-03%20at%2011.21.37%20PM.png) 在此看到Exiting status为0,结合大多数程序和系统脚本,结束状态码为0即为正常运行结束。 ### 开启 NameNode 和 DataNode 守护进程 运行`./sbin/start-dfs.sh`命令即可启动 ![Screen Shot 2017-05-03 at 11.23.40 P](https://source.mkshell.com/2017-05-04-Screen%20Shot%202017-05-03%20at%2011.23.40%20PM.png) 注意本次运行以及以后的运行程序有可能出现以下WARN: ``` WARN util.NativeCodeLoader: Unable to load native-hadoop library for your platform… using builtin-java classes where applicable ``` 该 WARN 提示可以忽略,并不会影响正常使用(该 WARN 可以通过编译 Hadoop 源码解决)。 而后即可通过 http://机器ip:50070 来访问 NameNode 和 Datanode 信息,还可以在线查看 HDFS 中的文件。 ![Screen Shot 2017-05-03 at 10.44.18 P](https://source.mkshell.com/2017-05-04-Screen%20Shot%202017-05-03%20at%2010.44.18%20PM.png) 在命令界面也可以通过`jps`来查看 NameNode 和 Datanode 信息是否在运行。 ## 运行例子 ### 命令查看 可以通过`./bin/hdfs dfs -help`命令来查看使用方法: ![Screen Shot 2017-05-03 at 11.24.41 P](https://source.mkshell.com/2017-05-04-Screen%20Shot%202017-05-03%20at%2011.24.41%20PM.png) 例如查看hadoop版本的命令: ![Screen Shot 2017-05-03 at 11.19.25 P](https://source.mkshell.com/2017-05-04-Screen%20Shot%202017-05-03%20at%2011.19.25%20PM.png) ### 在 HDFS 中创建用户目录 先通过`./bin/hdfs dfs -mkdir -p /user/hadoop`命令创建一个用户目录 ### 例子一 解析配置文件 本次实验可以就地取材,选择hadoop的配置文件为材料,使用伪分布式hadoop平台来解析。先用put命令把配置文件都放入hdfs的一个新建文件夹input: ``` ./bin/hdfs dfs -mkdir /user/hadoop/input ./bin/hdfs dfs -put ./etc/hadoop/*.xml /user/hadoop/input/ ./bin/hdfs dfs -ls input ``` ![Screen Shot 2017-05-03 at 11.08.16 P](https://source.mkshell.com/2017-05-04-Screen%20Shot%202017-05-03%20at%2011.08.16%20PM.png) 然后运行例子,解析input里面的配置文件,并将解析结果放入output文件夹: ``` ./bin/hadoop jar ./share/hadoop/mapreduce/hadoop-mapreduce-examples-*.jar grep /user/hadoop/input/* /user/hadoop/output 'dfs[a-z.]+' ``` ![Screen Shot 2017-05-03 at 11.09.22 P](https://source.mkshell.com/2017-05-04-Screen%20Shot%202017-05-03%20at%2011.09.22%20PM.png) 把output里面的解析结果通过cat命令输出: ``` ./bin/hdfs dfs -cat /user/hadoop/output/* ``` ![Screen Shot 2017-05-03 at 11.09.52 P](https://source.mkshell.com/2017-05-04-Screen%20Shot%202017-05-03%20at%2011.09.52%20PM.png) 把output里面的解析结果从hdfs中取出(这个操作是常用的,尽管在伪分布式里面没什么意义,但是在真实环境下特别有用): ``` ./bin/hdfs dfs -get /user/hadoop/output ./output ``` ### 例子二 WordCount wordcount是一个hadoop内置的范例程序,用于统计单词数量。 首先,要删除例子一留下的实验痕迹: ``` ./bin/hdfs dfs -rm -r -f /user/hadoop/output /user/hadoop/input ``` ![Screen Shot 2017-05-03 at 11.15.36 P](https://source.mkshell.com/2017-05-04-Screen%20Shot%202017-05-03%20at%2011.15.36%20PM.png) 然后和实验一一样,创建hadoop的输入文件夹,同时就地取材一个可读文件: ``` ./bin/hdfs dfs -mkdir /user/hadoop/input ./bin/hdfs dfs -put LICENSE.txt /user/hadoop/input/LICENSE.txt ./bin/hdfs dfs -ls /user/hadoop/input ``` 运行例子,结果放置在output文件夹: ``` ./bin/hadoop jar share/hadoop/mapreduce/hadoop-mapreduce-examples-2.8.0.jar wordcount /user/hadoop/input /user/hadoop/output ./bin/hdfs dfs -cat /user/hadoop/output/* ``` ![Screen Shot 2017-05-03 at 11.16.23 P](https://source.mkshell.com/2017-05-04-Screen%20Shot%202017-05-03%20at%2011.16.23%20PM.png) 可以看到,屏幕是被刷了好几遍,因为输出太多了,我们可以统计一下输出了有多少行: ``` ./bin/hdfs dfs -cat /user/hadoop/output/* | wc -l ``` ![Screen Shot 2017-05-03 at 11.17.34 P](https://source.mkshell.com/2017-05-04-Screen%20Shot%202017-05-03%20at%2011.17.34%20PM.png) 噢,原来有2000多行的输出呢! ## 结论 hadoop的思想已经融入了我们很多分布式系统里面,熟悉hadoop的思想和操作有助于我们更宏观地观察这个现代化网络世界。 若有错误之处请指出,更多地关注[煎鱼](http://www.jianyujianyu.com)。 https://www.mkshell.com/posts/2017-04-27-using-github-and-jenkins-deploy-hexo/ https://www.mkshell.com/posts/2017-04-27-using-github-and-jenkins-deploy-hexo.md 2017-04-27 # [小题大做] Github + Jenkins 实现自动化部署 hexo 博客静态文件 _使用jenkins来部署hexo简直就是小题大做,但是偶尔这样小题大做还真有折腾的乐趣_ ## 背景 [jianyujianyu.com](https://www.jianyujianyu.com)之前是使用typecho做博客的。 原因很简单,wordpress太重,hexo很轻没错,可是这个没有后台管理(听说可以折腾成有后台管理的),每次写完博客都要deploy一下的hexo,我真心要不起。 而现在[jianyujianyu.com](https://www.jianyujianyu.com)是hexo了,经过也很简单,就是因为CA证书过期了,换证书的时候弄砸了,本着顺便过滤一波辣鸡博客的心思,重新弄,这次选择了hexo,不为什么,人生的选择就是这么奇妙。 那怎么不自己手写一个?说得好!不过我才不要。 用hexo不是不行,是要考虑deploy的事,或者,我换台电脑写博客,我没有这个hexo的node环境,我也不想登录服务器用着vim来写,比如我现在就安静在公司电脑上写博客。在这样的情况下,怎么deploy呢?在从前,wordpress和typecho有着后台管理,这个问题根本是不存在的。 想着想着,一拍脑袋,就直接用jenkins了,然后把代码放在github,包括hexo的整个文件夹。至于为啥用github而不用coding甚至是github pages,never mind。 设计的deploy过程很简单: - 我先clone了github上的hexo文件夹,添加了md文章,然后push - github整理好代码就通知jenkins:我这里资源更新了,大爷快来玩啊 - jenkins收到消息,兽性大发地下了github上的代码 - 拥有hexo环境的jenkins直接执行hexo g,产生了分泌物,也就是静态文件,在这里就是[jianyujianyu.com](https://www.jianyujianyu.com)的html网页内容(hexo/public) - 待定的nginx直接将用户请求拉向[jianyujianyu.com](https://www.jianyujianyu.com)的hexo/public文件夹即可 简单地设计完,便是动手开始。 ## 环境准备 _所有操作都是在Ubuntu的root用户下进行的,其他环境自行处理或转换_ ### Java、Tomcat和Jenkins jenkins是java的美好产物,为了后面调试起来简单,使用tomcat容器来装着这产物,而不是直接用apt下载。 (如果为了快捷,也可以只下载java和jenkins,然后通过`java -jar jenkins.war`来运行。不推荐。) 因此我们首先安装J8。 ``` add-apt-repository ppa:webupd8team/java apt update apt install oracle-java8-installer ``` 之后是安装tomcat。对应着J8,选tomcat8。先去[tomcat官网](http://tomcat.apache.org/)下载包,然后解压。 ``` wget http://mirrors.hust.edu.cn/apache/tomcat/tomcat-8/v8.0.43/bin/apache-tomcat-8.0.43.tar.gz tar zxvf apache-tomcat-8.0.43.tar.gz ``` 接着是上[jenkins官网](https://jenkins.io)下载jenkins的war包。 ``` wget http://ftp-chi.osuosl.org/pub/jenkins/war-stable/2.46.1/jenkins.war ``` war包就放在tomcat文件夹的webapps下即可。 然后启动tomcat,进入tomcat文件夹的bin下,启动: ``` ./startup.sh ``` 接下来就可以尝试通过网页登录tomcat以及jenkins了。在浏览器中打开[http://服务器的ip:8080](#), ![tomcat主页](https://source.mkshell.com/BaiduHi_2017-4-25_19-45-36.png) 然后打开[http://服务器的ip:8080/jenkins](#),根据知识安装jenkins环境,安装推荐环境即可,不需要自己选择,因为推荐环境已经包含了我们需要的插件。**_如果要nginx反向代理tomcat,推荐先做完后面nginx的反向代理再安装jenkins_**。 按照提示输入password,然后选择推荐安装: ![](https://source.mkshell.com/BaiduHi_2017-4-26_11-23-43.png) ![](https://source.mkshell.com/BaiduHi_2017-4-26_11-28-19.png) 正在安装,要等一下 ![](https://source.mkshell.com/BaiduHi_2017-4-26_11-28-30.png) 账号密码以及权限的事自行考虑 ![](https://source.mkshell.com/BaiduHi_2017-4-26_12-20-38.png) ### Nodejs、NPM和hexo hexo是nodejs的产物,因此搭建nodejs环境是必须的。而安装nodejs推荐在[官网](https://nodejs.org/en/)上下载源码编译,不然apt得到的都是旧的,没办法,node这两年发展得太快了。 ``` wget https://nodejs.org/dist/v6.10.2/node-v6.10.2.tar.gz tar zxvf node-v6.10.2.tar.gz cd node-v6.10.2 ./configure make make install ``` make可自行通过参数-j来加速编译。 安装好了node,npm也接着来了,如果在安装过程中觉得npm下载太慢可以是用[淘宝镜像](http://npm.taobao.org/),使用方法自行查询。 然后照着[hexo官方文档](https://hexo.io/docs/)的方法安装hexo已经相关环境 ``` npm install hexo-cli -g hexo init hexo cd hexo npm install ``` hexo的命令很简单,有了hexo的环境之后可以通过命令生成静态文件或者启动临时服务器: ``` hexo g # generate static file hexo s # start a server ``` 在生成静态文件后,将nginx配置到hexo/public下面,请求即可到达;在启动hexo临时服务器后,即可访问http://服务器的ip:4000/jenkins。 至此,hexo环境已经是完成了。 如果说还有什么需要在这一步做的话, 那应该就是将代码push到git上,这个事我就不多说了。 ### Nginx 安装nginx十分简单,但是在这次的实验中,nginx地位也不算低,80或者443端口要配置到hexo/public下,最好还要给tomcat配置反向代理。在以后的安全和优化方面,nginx需要配置的也不少。 安装nginx也就一句 ``` apt install nginx ``` 然后便是对配置的修改(我直接改default配置文件,别的方法自行实施) ``` vim /etc/nginx/sites-available/default ``` 在开放80或者443的端口的server里面配置好root,例: ``` root /var/www/hexo/public; ``` 推荐给tomcat上个反向代理, ``` upstream tomcat { server 127.0.0.1:8080; } server { listen 11111; listen [::]:11111; location / { #try_files $uri $uri/ =404; proxy_pass http://tomcat; proxy_redirect off; proxy_set_header Host $host:$server_port; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Request-Url $request_uri; } } ``` 以上需要注意的是proxy_set_header需要配置好,不然后面可能无法跳转到对的路径。 ## 配置Github和Jenkins ### 创建job ![创建job](https://source.mkshell.com/Screen%20Shot%202017-04-25%20at%2010.43.29%20PM.png) 点击create new jobs,选择freestyle,填好job名字。 ![选择freestyle](https://source.mkshell.com/Screen%20Shot%202017-04-25%20at%2010.44.11%20PM.png) 创建了job以后,jenkins紧接着就是让你配置jenkins,如果这次不配置也没什么,因为创建的job已经保存好了。 ### 配置密钥 如果我在github上的代码库是私密的而不是公开的,那么jenkins想要拉取到我的代码必须要有相应的私钥。至于哪一台机上的公钥私钥没什么所谓,重要的是github上有一台机的公钥,而jenkins上应该也有这台机的私钥。当然前提是github上的代码库是私密的,公开的无所谓。 先在Jenkins添加私钥。 ![创建一个证书](https://source.mkshell.com/Screen%20Shot%202017-04-25%20at%2010.48.36%20PM.png) ![](https://source.mkshell.com/Screen%20Shot%202017-04-25%20at%2010.48.56%20PM.png) ![填入git平台的公钥对应的私钥](https://source.mkshell.com/Screen%20Shot%202017-04-25%20at%2010.52.30%20PM.png) 查出private key往上填就行,为了保密我就不填真实username了。至于怎么生成key,自行查找ssh-keygen命令,或者参考类似[Github的文档](https://help.github.com/articles/connecting-to-github-with-ssh/)。 ### 配置WebHook 配置webhook是在github上完成的。webhook的存在意义就是github上发生了特定事件,然后会告诉请求jenkins,以便让jenkins去拉代码进行下一步操作。 例如,现在要配置的,就是每次将代码push上去后,就触发jenkins的操作。 ![](https://source.mkshell.com/BaiduHi_2017-4-26_10-29-15.png) 打码处理。 ![](https://source.mkshell.com/BaiduHi_2017-4-26_10-33-0.png) ### 配置job 之前的环境都搭建好了,配置刚才创建的job就是关键的一步了。 首先是要配置github project ![](https://source.mkshell.com/Screen%20Shot%202017-04-26%20at%2011.49.16%20PM.png) 而在源码管理里面,必须要填一个可行的、同时有权限的配置,失败则如下图,必须做到没有错误提醒 ![](https://source.mkshell.com/Screen%20Shot%202017-04-26%20at%2011.53.25%20PM.png) browser也顺便一填 ![](https://source.mkshell.com/Screen%20Shot%202017-04-26%20at%2011.53.34%20PM.png) 而在Build Triggers里面必须要选Github hook trigger,勾选了jenkins才会接受github发来的请求 ![](https://source.mkshell.com/Screen%20Shot%202017-04-26%20at%2011.49.53%20PM.png) 最后在Build,也就是构建的这部分,也填上,推荐使用shell,毕竟现在玩这些都是linux了 ![](https://source.mkshell.com/Screen%20Shot%202017-04-26%20at%2011.51.15%20PM.png) 选择shell后,可以在文本框里面填自己部署要用到的命令,如下面的例子,cd、删除、拷贝等命令都可以。 需要注意的是,某些命令没有,这个可以通过不断的测试来检验。 ![](https://source.mkshell.com/Screen%20Shot%202017-04-26%20at%2011.52.21%20PM.png) 至此已经配置好了jenkins上的job,接下来就是调试了。 ## 调试 调试很简单。先把代码clone下来本地,然后稍稍改动一个无关文件,再push,之后便是感受是否这套环境是否能自动化部署。 ### Push代码 各种push方式任君选择 ![](https://source.mkshell.com/Screen%20Shot%202017-04-27%20at%2012.11.57%20AM.png) ### 查看日志 push成功后,github hook会通知所填写的jenkins,如果jenkins没有调起job,则有可能是hook或者配置job里面的Github hook trigger没有填写正确。 如果是成功调起,在jenkins的hexo的job页面,左下角会看到有类似`#1`的 ![](https://source.mkshell.com/Screen%20Shot%202017-04-27%20at%2012.12.20%20AM.png) 想要查看错误日志,点击左上角的Github Hook Log或者Console Output,即可根据这些日志对配置进行调整。 ![](https://source.mkshell.com/Screen%20Shot%202017-04-27%20at%2012.12.57%20AM.png) Output大致都如下: ![](https://source.mkshell.com/BaiduHi_2017-4-28_10-25-53.png) ### 访问网站 不是红灯一般都是成功的,访问网站体验一下,[jianyujianyu.com](https://www.jianyujianyu.com/) : ![](https://source.mkshell.com/BaiduHi_2017-4-28_10-28-45.png) 没毛病。 若有错误之处请指出,更多地关注[煎鱼](http://www.jianyujianyu.com)。 https://www.mkshell.com/posts/2017-03-09-ubuntu14-04-cuda8-caffe-cudnn5-0-gtx1080-gtx750ti/ https://www.mkshell.com/posts/2017-03-09-ubuntu14-04-cuda8-caffe-cudnn5-0-gtx1080-gtx750ti.md 2017-03-09 # ubuntu14.04 + cuda8 + caffe + cudnn5.0 (GTX1080 + GTX750Ti) _工作室的内网出了问题,不能修改以前写的文档,于是我便修改了放在个人网站_ ## 版本 linux系统:Ubuntu 14.04.5 (64位) 显卡:GTX1080 + GTX750Ti cuda: cuda_8.0.27_linux.run (传送门:https://developer.nvidia.com/cuda-toolkit) 显卡驱动:NVIDIA-Linux-x86_64-367.27.run (传送门:http://www.geforce.cn/drivers/results/104314) cudnn:cudnn-5.1 ( for CUDA 8 rc) (传送门:https://developer.nvidia.com/cudnn) BLAS:BLAS选用intel的MKL parallel_studio_xe_2016,或者Atlas 以下默认使用root用户,sudo已忽略 ## 安装ubuntu: **需要注意事项:** ubuntu14.04默认开源驱动不兼容GTX1080,因此需要先拔掉GTX1080或将GTX750Ti置于PCI优先位置 在显示屏使用GTX750Ti的情况下,安装以及启动ubuntu ## 禁用nouveau驱动 按Ctrl+Alt+F1进入命令提示符,新建一个黑名单文件 `vim /etc/modprobe.d/blacklist-nouveau.conf` 输入 `blacklist nouveau options nouveau modset=0` 保存退出(:wq) 然后执行 `update-initramfs -u` 执行 `lspci | grep nouveau`查看是否有内容 如果没有内容 ,说明禁用成功,如果有内容,就重启一下再查看 `reboot` 重启后,进入登录界面的时候,不要登录进入桌面,直接按Ctrl+Alt+F1进入命令提示符。 ## 安装CUDA和NVIDIA驱动 (目前使用最新的CUDA里面包含的NVIDIA驱动足矣) `chmod 755 NVIDIA-Linux-x86_64-367.27.run //获取权限` `./NVIDIA-Linux-x86_64-367.27.run //安装驱动` 安装后就可以用nvidia-smi命令查看显卡和驱动情况。 `chmod 755 cuda_8.0.27_linux.run` `./cuda_8.0.27_linux.run` PS:8.0.27可能已经过旧,请到官网下载最新版 需要注意该版本CUDA8中的NVIDIA驱动太旧,而且我们已经安装了,所以安装CUDA过程中唯有驱动不安装 除了第二项“”是否安装显卡驱动“选择no之外,其他全部按照默认设定 安装过程显示如下信息 ``` Do you accept the previously read EULA? accept/decline/quit: accept Install NVIDIA Accelerated Graphics Driver for Linux-x86_64 361.62? (y)es/(n)o/(q)uit: n Install the CUDA 8.0 Toolkit? (y)es/(n)o/(q)uit: y Enter Toolkit Location [ default is /usr/local/cuda-8.0 ]: Do you want to install a symbolic link at /usr/local/cuda? (y)es/(n)o/(q)uit: y Install the CUDA 8.0 Samples? (y)es/(n)o/(q)uit: y Enter CUDA Samples Location [ default is /home/zhou ]: Installing the CUDA Toolkit in /usr/local/cuda-8.0 … Missing recommended library: libGLU.so Missing recommended library: libX11.so Missing recommended library: libXi.so Missing recommended library: libXmu.so Installing the CUDA Samples in /root … Copying samples to /root/NVIDIA_CUDA-8.0_Samples now… Finished copying samples. . . . ``` 设置环境变量 (error while loading shared libraries: xxx.so.x" 错误的原因和解决办法) `vim /etc/profile` 加上两句 ``` export PATH=/usr/local/cuda/bin:$PATH export LD_LIBRARY_PATH=/usr/local/cuda/lib64:$LD_LIBRARY_PATH ``` `vim /etc/ld.so.conf.d/cuda.conf` 中添加 `/usr/local/cuda/lib64` 执行`ldconfig` ## make CUDA库 安装依赖: ``` apt-get install freeglut3-dev build-essential libx11-dev libxmu-dev libxi-dev libglu1-mesa libglu1-mesa-dev libgl1-mesa-glx cd /usr/local/cuda/samples make all -j8 ``` 以上的make编译时为了测试cuda库和环境安装成功并编译玩一下。 `cd ./bin/x86_64/linux/release` `./deviceQuery` 查询显卡信息 ![](https://source.mkshell.com/Cache_3fb1040ea54e9bed..jpg) ## 安装CUDNN5.0 解压好CUDNN得到cuda目录 ``` cd cuda sudo cp lib64/lib* /usr/local/cuda/lib64/ sudo cp include/cudnn.h /usr/local/cuda/include/ cd /usr/local/cuda/lib64/ sudo chmod +r libcudnn.so.5.0.5 sudo ln -sf libcudnn.so.5.0.5 libcudnn.so.5 sudo ln -sf libcudnn.so.5 libcudnn.so sudo ldconfig ``` ## 安装OPENCV 推荐安装pyenv来管理python版本环境(自行查找) 提前安装libegl的库,免得安装到一并报错: `apt install libegl1-mesa libegl1-mesa-drivers` 参考https://github.com/jayrambhia/Install-OpenCV一键安装opencv ## 编译安装Caffe ### 安装依赖: ``` apt-get install libprotobuf-dev libleveldb-dev libsnappy-dev libopencv-dev libhdf5-serial-dev protobuf-compiler libatlas-base-dev libgflags-dev libgoogle-glog-dev liblmdb-dev libboost-all-dev ``` ### 如果不用mkl,而是用atlas,则安装: ``` apt-get install libatlas-dev libatlas-base-dev ``` ### 所有python依赖:(按照不同情况自行查看关系安装) ``` apt-get install python-dev python-pip cython python-numpy python-scipy python-opencv ``` ### 在github上clone Caffe: https://github.com/BVLC/caffe 进入其目录,并按照实际修改Makefile.config,主要是启用CUDNN,改用MKL(如果安装了),然后编译: ``` git clone https://github.com/BVLC/caffe cd caffe make all -j8 make test -j8 make runtest -j8 ``` PS:j后面的8表示启用多少线程来编译,视性能而定 如果还需要使用pycaffe:需要对pycaffe进行编译: `make pycaffe -j8` 而后进入python目录,用pip安装依赖: ``` apt-get install python-pip cd python pip install -r requirements.txt ``` 顺手把graphviz也安装了: ``` apt-get install graphviz graphviz-dev pip install pydot pip install pygraphviz ``` 最后添加pythonpath的环境变量: `vim /etc/profile` 添加`export PYTHONPATH=/root/caffe/python:$PYTHONPATH # path to pytho in caffe` 然后执行 `source /etc/profile` 若有错误之处请指出,更多地关注[煎鱼](http://www.jianyujianyu.com)。 https://www.mkshell.com/posts/2017-03-05-chinese-unicode-in-python2-x/ https://www.mkshell.com/posts/2017-03-05-chinese-unicode-in-python2-x.md 2017-03-05 # Python 2.x 的中文编码问题 Python 2.x的默认编码格式是ASCII,就是说,在没有指定Python源码编码格式的情况下,源码中的所有字符都会被默认为ASCII码。因此,在Python 2.x中经常会遇到UnicodeDecodeError或者UnicodeEncodeError的异常。 ## 常见异常以及分析 ### SyntaxError: Non-ASCII character Python源码文件中有非ASCII字符,而且同时没有声明源码编码格式 ### UnicodeDecodeError ``` #!/usr/bin/python # -*- coding: utf-8 -*- s = '中文' s.decode('gb2312') # UnicodeDecodeError: 'gb2312' codec can't decode bytes in position 2-3: illegal multibyte sequence print s ``` 以上的s已经是声明了的utf8,但在转换为Unicode的时候传的不是utf8而是gb2312 ``` #!/usr/bin/python # -*- coding: utf-8 -*- s = '中文' s.encode('gb2312') # UnicodeDecodeError: 'ascii' codec can't decode byte 0xe4 in position 0: ordinal not in range(128) print s ``` 与前一种情况一样,都是当前字符编码和de/encode的参数编码冲突 ### UnicodeEncodeError 如将Unicode编码的字符decode,或者将utf8编码的字符encode的时候 ``` #!/usr/bin/python # -*- coding: utf-8 -*- s = u'中文' s.decode('utf-8') # UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-1: ordinal not in range(128) print s ``` ## 如何避免 1. 在文件头声明编码,即PEP0263 2. 用u'string'替代'string' 3. 使用sys.setdefaultencoding('utf-8')来reset编码 4. 原则:decode early, unicode everywhere, encode late 5. 升级Python 3 ## 顺便一提Python 3的Unicode 1. 默认编码即为Unicode 2. 所有Python的内置模块都支持Unicode 3. 不再支持u'string'的格式 [原文链接](https://segmentfault.com/a/1190000002412924) 若有错误之处请指出,更多地关注[煎鱼](https://www.jianyujianyu.com)。 https://www.mkshell.com/posts/2017-03-03-thinking-about-mybatis-from-jdbc/ https://www.mkshell.com/posts/2017-03-03-thinking-about-mybatis-from-jdbc.md 2017-03-03 # 从JDBC的不足思考Mybatis的构建 ## 用JDBC实现对数据上的操作 ```java public static List> queryForList(){ Connection connection = null; ResultSet rs = null; PreparedStatement stmt = null; List> resultList = new ArrayList>(); try { // 1.加载JDBC驱动 Class.forName("oracle.jdbc.driver.OracleDriver").newInstance(); String url = "jdbc:oracle:thin:@localhost:1521:ORACLEDB"; String user = "trainer"; String password = "trainer"; // 2.获取数据库连接,这里需要填写数据库的信息,包括账号密码 connection = DriverManager.getConnection(url,user,password); // 创建查询语句 String sql = "select * from userinfo where user_id = ? "; // 3.创建Statement对象(每一个Statement为一次数据库执行请求 stmt = connection.prepareStatement(sql); // 4.设置传入参数 stmt.setString(1, "zhangsan"); // 5.执行SQL语句 rs = stmt.executeQuery(); // 6.处理查询结果(将查询结果转换成List格式) ResultSetMetaData rsmd = rs.getMetaData(); int num = rsmd.getColumnCount(); while(rs.next()){ Map map = new HashMap(); for(int i = 0;i < num;i++){ String columnName = rsmd.getColumnName(i+1); map.put(columnName,rs.getString(columnName)); } resultList.add(map); } } catch (Exception e) { e.printStackTrace(); } finally { // 7. 释放所有资源 try { //关闭结果集 if (rs != null) { rs.close(); rs = null; } //关闭执行 if (stmt != null) { stmt.close(); stmt = null; } if (connection != null) { connection.close(); connection = null; } } catch (SQLException e) { e.printStackTrace(); } } return resultList; } ``` ## 从JDBC中思考Mybatis的构建 MyBatis是将sql语句中的输入参数和输出参数映射为java对象,放弃了对数据表的完整性控制,但是获得了更灵活和响应性能更快的优势。 代码注释中标出的7步,在开发中显得略微复杂繁琐,因此在此思考Mybatis是如何改进JDBC的缺点的。 ### 连接获取和释放 如果每次请求数据库都获取连接然后释放,则过于浪费性能;如果都放在一个try里面,则增加代码复杂度以至于难以阅读。可以是用连接池以重复使用连接,但是连接池的多样化问题略为严重。 **解决:**通过一个中间层DataSource进行解耦,统一从DataSource获取连接,DataSource可通过配置选择不同的连接池(DBCP、JNDI)。 ### SQL统一存取 以上例子中的查询语句依然使用SQL字符串,并且还略为发散,根据大佬的话,有三处不足: - 可读性很差,不利于维护以及做性能调优 - 改动Java代码需要重新编译、打包部署 - 不利于取出SQL在数据库客户端执行 **解决:**将SQL语句统一集中放到配置文件或者数据库里面(以key-value的格式存放),但涉及读取SQL语句的加载问题。 ### 传入参数映射和动态SQL 通过在SQL语句中设置占位符来传参,就要求参数与占位符一一匹配,当传参不确定时,还是得自己拼接于是还是只能在代码中写SQL,因此需要方法根据不同的传参动态生成。 **解决:**类似if-else清晰的结构,使用key-value的Map,在解析的时候根据变量名的具体值来判断,同时使用一种有别于SQL的语法来嵌入变量(如`#变量名#`),如此便可生成符合上下文的SQL语句。 同时,要区分开占位符变量和非占位变量,可以使用`#变量名#`表示占位符变量,使用`$变量名$`表示非占位符变量。 ### 结果映射和结果缓存 执行SQL语句、获取执行结果、对执行结果进行转换处理、释放相关资源的步骤必须是有序的,否则会出错。(如,获取结果不能在释放之后)因此可以封装起来,但主要的是处理结果,如果能把常用的结果处理方法都到位,就可将整个过程封装起来。 **解决:**由于返回和处理结果的多样性,必须要设置两个参数:返回对象的类型,返回数据与处理结构的映射。同时,采用KV缓存提供SQL的性能,而为了保证key的唯一,key采用SQL语句和参数。 ### 重复SQL语句问题 个别语句只是条件不同,主体大致,而表结构的改动促使代码多处修改导致不利于维护。 **解决:**将重复的代码抽离出来成为独立的一个类,然后在各个需要使用的地方进行引用,即模块化。 ## 总结 Hibernate属于全自动,Mybatis属于半自动,JDBC属于手动 从开发效率上讲Hibernate较高,Mybatis居中,JDBC较低 从执行效率上讲Hibernate较低,Mybatis居中,JDBC较高 ## 其他问题:ibatis与Mybatis的区别 ibatis是Mybatis的前身 - Mybatis实现了接口绑定,使用更加方便 - 对象关系映射的改进,效率更高 - MyBatis采用功能强大的基于OGNL的表达式来消除其他元素 若有错误之处请指出,更多地关注[煎鱼](https://www.jianyujianyu.com)。 https://www.mkshell.com/posts/2017-01-14-http-server-implement-using-niosocket/ https://www.mkshell.com/posts/2017-01-14-http-server-implement-using-niosocket.md 2017-01-14 # 使用NioSocket手动实现HTTP服务器 ## NioSocket简单复习 ### 重要概念 NioSocket里面的三个重要概念:Buffer、Channel、Selector - Buffer为要传输的数据 - Channel为传输数据的通道 - Selector为通道的分配调度者 ### 使用步骤 使用NioSocket实现通信大概如以下步骤: 1. ServerSocketChannel可以通过configureBlocking方法来设置是否采用阻塞模式,设置为false后就可以调用register注册Selector,阻塞模式下不可以用Selector。 2. 注册后,Selector就可以通过select()来等待请求,通过参数设置等待时长,若传入参数0或者不传入参数,将会采用阻塞模式直到有请求出现。 3. 接收到请求后Selector调用selectedKeys方法,返回SelectedKey集合。 4. SelectedKey保存了处理当前请求的Channel和Selector,并提供了不同的操作类型。四种操作属性:SelectedKey.OP_ACCEPT、SelectedKey.OP_CONNECT、SelectedKey.OP_READ、SelectedKey.OP_WRITE。 5. 通过SelectedKey的isAcceptable、isConnectable、isReadable和isWritable来判断操作类型,并处理相应操作。 6. 在相应的Handler中提取SelectedKey中的Channel和Buffer信息并执行相应操作。 ## 实现HTTP ### 创建HttpServer类作为程序的主要入口 ```java public class HttpServer { public static void main(String[] args) throws Exception{ ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); serverSocketChannel.socket().bind(new InetSocketAddress((8080))); serverSocketChannel.configureBlocking(false); Selector selector = Selector.open(); // It must be ACCEPT, or it will throw exception serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT); while(true){ if (selector.select(3000) == 0){ continue; } Iterator keyIter = selector.selectedKeys().iterator(); while (keyIter.hasNext()){ SelectionKey key = keyIter.next(); new Thread(new HttpHandler(key)).run(); keyIter.remove(); } } } } ``` 以上代码的逻辑大致遵循着NioSocket的大概用法,其中serverSocketChannel使用register方法注册到selector仅是OP_ACCEPT,使用其他操作就会操作。但是并不是说不能进行其他操作,而是其他操作稍后实现。 在serverSocketChannel.configureBlocking(false)后,非阻塞模式启动。Server接收到请求后就会将记录了请求信息的key交给HttpHandler做详细处理,处理完就把key从迭代器里面remove掉。可以看到出来,HttpServer对请求里面的信息一概不知,这样才能成为一个出色的管理层,它管理着HttpHandler来处理请求。 既然选用了NioSocket这样的New IO,HttpHandler必然是多线程的实现(否则还有什么意义)。 ### 创建HttpHandler来处理请求 对于来自HttpServer的不加工信息,HttpHandler必须要做全套,因此需要HttpHandler自己考虑好有没有中文乱码、Buffer大小是多少等等。HttpHandler大概框架如下即可: ```java class HttpHandler implements Runnable{ private int bufferSize = 1024; private String localCharset = "UTF-8"; private SelectionKey key; public HttpHandler(SelectionKey key){ this.key = key; } public void handleAccept() throws IOException{} public void handleRead() throws IOException{} @Override public void run() { try { if(key.isAcceptable()){ handleAccept(); } if(key.isReadable()){ handleRead(); } }catch (IOException ex){ ex.printStackTrace(); } } } ``` 如上框架简单明了,重载run实现多线程,handleAccept和handleRead用于详细地处理相关操作,bufferSize规定Buffer大小,localCharset的设定提前防止中文乱码。 需要注意的是HttpServer里面,我们只注册了OP_ACCEPT这个操作,那么在HttpHandler里面只有isAcceptable()判定为真,那么handleRead()怎么办呢?我们会在handleAccept()注册好的: ```java public void handleAccept() throws IOException{ SocketChannel clientChannel = ((ServerSocketChannel)key.channel()).accept(); clientChannel.configureBlocking(false); clientChannel.register( key.selector(), SelectionKey.OP_READ, ByteBuffer.allocate(bufferSize) ); } ``` 在handleAccept里面,我们先取得key里面的请求信息,如对应客户端的SocketChannel (SocketChannel需要ServerSocketChannel接受了后才有),接着就可以为SocketChannel注册OP_READ操作了,带上指定大小的Buffer。注册后,key可是isReadable()了,接下来则是在handleRead中对key进行解剖处理:(代码有点长,但大多是控制台输出和对字符串的拼接操作,看官可放心食用。) ```java public void handleRead() throws IOException{ SocketChannel sc = (SocketChannel)key.channel(); ByteBuffer buffer = (ByteBuffer)key.attachment(); buffer.clear(); if (sc.read(buffer) == -1){ sc.close(); }else { buffer.flip(); String receiveString = Charset.forName(localCharset).newDecoder().decode(buffer).toString(); String[] requestMessage = receiveString.split("\r\n"); for (String s: requestMessage){ System.out.println(s); if (s.isEmpty()){ break; } } String[] firstLine = requestMessage[0].split(" "); System.out.println(); System.out.println("Method:\t"+ firstLine[0]); System.out.println("url:\t"+firstLine[1]); System.out.println("HTTP Version:\t" + firstLine[2]); System.out.println(); StringBuilder sendString = new StringBuilder(); sendString.append("HTTP/1.1 200 OK\r\n"); sendString.append("Content-Type:text/html;Charset="+localCharset+"\r\n"); sendString.append("\r\n"); sendString.append("SHOW"); sendString.append("Received:
"); for (String s : requestMessage){ sendString.append(s + "
"); } sendString.append(""); buffer = ByteBuffer.wrap(sendString.toString().getBytes(localCharset)); sc.write(buffer); sc.close(); } } ``` handleRead开头先获取到对应的SocketChannel和ByteBuffer,就这两个最为关键,SocketChannel负责与客户端的链接和传输数据,而ByteBuffer充当数据运输的载体。 而后则是简单的判断连接状态,若是连接,将相关信息输出到控制台,并拼接出HTTP头的字符串发送至客户端。 效果如图: ![](https://source.mkshell.com/2017-01-15-14844539625326.jpg) ![](https://source.mkshell.com/2017-01-15-14844539849717.jpg) 若有错误之处请指出,更多地关注[煎鱼](https://www.jianyujianyu.com)。
https://www.mkshell.com/posts/2016-10-14-compile-tensorflow-on-raspberry2or3/ https://www.mkshell.com/posts/2016-10-14-compile-tensorflow-on-raspberry2or3.md 2016-10-14 # 在树莓派2或3中编译谷歌深度学习框架TensorFlow _积尘多年的树莓派2B一直都是用于WEB打洞(ngrok+Wetty),一天突发奇想的煎鱼我,想结合最近研究的DL给树莓派点任务跑跑。然而,首先我得有一个树莓派的TF环境。_ ## 需要做好的准备 - 树莓派2或3B - 一张已经装好Raspbian的SD卡(推荐16G) - 树莓派能联网(有的可能需要FQ) - 一个外置U盘,用作SWAP(要无驱,不然你就得想怎么装驱动) - 很多很多的折腾时间(心理准备哈哈) ## 梗概 本篇文章专门针对运行着Raspbian 8.0 (jessie)的树莓派3B。在树莓派2上应该是可行的,但可能会有一些小问题。(所有操作默认是root用户,权限问题请自行注意。) 按照计划,我们将会安装: 1. 32位的Protobuf 2. 对树莓派比较友好的Bazel 3. 用Bazel编译TensorFlow ## 编译 ### 安装基本依赖 更新源: ``` apt-get update ``` Protobuf的依赖: ``` apt-get install autoconf automake libtool maven ``` Bazel的依赖: ``` apt-get install pkg-config zip g++ zlib1g-dev unzip ``` TensorFlow的依赖: ``` # 对于Python 2.7的 apt-get install python-pip python-numpy swig python-dev pip install wheel # 对于Python 3.3+的 apt-get install python3-pip python3-numpy swig python3-dev pip3 install wheel ``` 最后,为了你的文件能整洁点,建议你建一个文件夹来管理这次所用到的所有文件: ``` mkdir tf cd tf ``` ### 编译Protobuf 克隆Protobuf库: ``` git clone https://github.com/google/protobuf.git ``` 然后进入文件夹,编译(这里需要花费一点时间): ``` cd protobuf git checkout d5fb408d ./autogen.sh ./configure --prefix=/usr make -j 4 make install ``` 注:autogen.sh脚本中有一段代码是为了下载google的gmock,若连不上或者网络太慢可以使用一下办法: ``` cd protobuf git checkout d5fb408d apt-get install google-gmock autoreconf -f -i -Wall,no-obsolete rm -rf autom4te.cache config.h.in~ ./configure --prefix=/usr make -j 4 make install ``` 编译完成了之后,进入java文件用maven来建立项目: ``` cd java mvn package ``` 注:这次用maven慢也没啥办法了,国内的OSCHINA源好像是不做了 新注:找到了阿里云的源,速度快多了,在/etc/maven/settings.xml中的mirrors标签中添加: ``` nexus-aliyun central Nexus aliyun http://maven.aliyun.com/nexus/content/groups/public ``` 当你做完以上这些,你就会发现多了两个新文件: - /usr/bin/protoc - protobuf/java/core/target/protobuf-java-3.0.0-beta2.jar ### 编译Bazel 首先退出上两级,然后克隆Bazel库: ``` cd ../.. git clone https://github.com/bazelbuild/bazel.git ``` 然后进入bazel文件夹并切换版本: ``` cd bazel git checkout 0.2.1 ``` 将之前提出的生成的两个文件复制进来,注意名字(至于为啥是这样的名字,估计是不想改某些文件) ``` cp /usr/bin/protoc third_party/protobuf/protoc-linux-arm32.exe cp ../protobuf/java/target/protobuf-java-3.0.0-beta-2.jar third_party/protobuf/protobuf-java-3.0.0-beta-1.jar ``` 在编译Bazel之前,我们需要需要为这次编译设置javac的最大堆大小,否则我们会得到OutOfMemoryError错误。因此,我们需要修改一个小文件bazel/scripts/bootstrap/compile.sh: ``` vim scripts/bootstrap/compile.sh ``` 去到128行,你会看见这样一段代码: ``` run "${JAVAC}" -classpath "${classpath}" -sourcepath "${sourcepath}" \ -d "${output}/classes" -source "$JAVA_VERSION" -target "$JAVA_VERSION" \ -encoding UTF-8 "@${paramfile}" ``` 在这段代码的最后加上一个参数`-J-Xmx500M`,意思就是设置了Java最大堆大小为500M,修改后如下: ``` run "${JAVAC}" -classpath "${classpath}" -sourcepath "${sourcepath}" \ -d "${output}/classes" -source "$JAVA_VERSION" -target "$JAVA_VERSION" \ -encoding UTF-8 "@${paramfile}" -J-Xmx500M ``` 然后让我们开始愉快地编译Bazel吧: ``` ./compile.sh ``` 编译完成后移动可执行文件: ``` mkdir /usr/local/bin cp output/bazel /usr/local/bin/bazel ``` 然后先确认一下Bazel是否已经成功安装了,我们是运行一下,它应该会出现帮助信息的: ``` bazel Usage: bazel ... Available commands: analyze-profile Analyzes build profile data. build Builds the specified targets. canonicalize-flags Canonicalizes a list of bazel options. clean Removes output files and optionally stops the server. dump Dumps the internal state of the bazel server process. fetch Fetches external repositories that are prerequisites to the targets. help Prints help for commands, or the index. info Displays runtime info about the bazel server. mobile-install Installs targets to mobile devices. query Executes a dependency graph query. run Runs the specified target. shutdown Stops the bazel server. test Builds and runs the specified test targets. version Prints version information for bazel. Getting more help: bazel help Prints help and options for . bazel help startup_options Options for the JVM hosting bazel. bazel help target-syntax Explains the syntax for specifying targets. bazel help info-keys Displays a list of keys used by the info command. ``` 如果bazel命令没有问题,那我们就可以移动到上一级,继续下一步了: ``` cd .. ``` ### 安装一个编译需要的SWAP 想要成功编译Tensorflow,树莓派需要更多的内存空间(原来就只有1G),因此我们需要建立一个SWAP空间来扩展一下运行内存,找一个1G以上的闲置U盘吧。 先插入你的U盘,找一下设备的路径`/dev/XXX`: ``` blkid ``` 举个例子,我的U盘设备的路径是`/dev/sda1`。找到你的设备的路径后,卸载(推出)它而后格式化它: ``` umount /dev/XXX mkswap /dev/XXX ``` 现在我们需要查询那个U盘的SWAP分区的UUID: ``` blkid ``` 用查询到的UUID修改/etc/fstab文件,这样做才可以加载U盘中的SWAP分区: ``` vim /etc/fstab # 加入下面这行,替换XX文本 UUID=XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX none swap sw,pri=5 0 0 ``` 保存文件后,运行一下命令以激活SWAP分区: ``` swapon -a ``` 注:如果出错了,就试一下修改/etc/fstab,用`/dev/XXX`替换`UUID=XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX`: ``` /dev/XXX none swap sw,pri=5 0 0 ``` 好了,现在就已经加载了SWAP分区了,不过先别忘记你的`/dev/XXX`,迟点还要在卸载它的时候用上。 ### 编译Tensorflow 终于到了这一步了,克隆tensorflow库然后进入,并执行替换: ``` git clone --recurse-submodules https://github.com/tensorflow/tensorflow cd tensorflow grep -Rl 'lib64'| xargs sed -i 's/lib64/lib/g' ``` 而后我们需要删除`tensorflow/core/platform/platform.h`中特定的一行: ``` vim tensorflow/core/platform/platform.h ``` 删除`#define IS_MOBILE_PLATFORM`: ``` #elif defined(__arm__) #define PLATFORM_POSIX ... #define IS_MOBILE_PLATFORM <----- 删除这一行 ``` 以上这样做就是为了让tensorflow认为我们的树莓派是一个手机(TF有手机版本) 然后我们开始配置编译(终于用到之前的Bazel): 注:如果是Python3的用户,请填写`/usr/bin/python3` ``` ./configure Please specify the location of python. [Default is /usr/bin/python]: /usr/bin/python Do you wish to build TensorFlow with Google Cloud Platform support? [y/N] N Do you wish to build TensorFlow with GPU support? [y/N] N ``` 然后我们就正式开始编译Tensorflow了,**注意了,这要用很长很长很长很长的时间编译!**(n小时吧)。 ``` bazel build -c opt --copt="-mfpu=neon" --local_resources 1024,1.0,1.0 --verbose_failures tensorflow/tools/pip_package:build_pip_package ``` 注:以上这个编译语句只用了一个核,作者曾试过用4个核,但似乎会有锁。如果你想试试的话(你想怎么样都行,不关我事),可以修改一下参数(四核): ``` bazel build -c opt --copt="-mfpu=neon" --local_resources 1024,4.0,1.0 --verbose_failures tensorflow/tools/pip_package:build_pip_package ``` 当你第二天起床醒来看到编译完成了,是时候该用上编译出来的二进制可执行文件来生成一个Python wheel了: ``` bazel-bin/tensorflow/tools/pip_package/build_pip_package /tmp/tensorflow_pkg ``` 然后用pip(3)来安装它: ``` pip install /tmp/tensorflow_pkg/tensorflow-0.9-cp27-none-linux_armv7l.whl ``` ### 清理残局 是时候该关闭SWAP分区了,如果你已经不需要了: ``` swapoff /dev/XXX ``` 然后在`/etc/fstab`中删除或注释掉之前你写出来的那一行,然后重启即可。 **嗯,终于完成了。** [原文链接](https://github.com/samjabrahams/tensorflow-on-raspberry-pi/blob/master/GUIDE.md) 若有错误之处请指出,更多地关注[煎鱼](http://www.jianyujianyu.com)。 https://www.mkshell.com/posts/2016-09-02-thinking-about-cnn/ https://www.mkshell.com/posts/2016-09-02-thinking-about-cnn.md 2016-09-02 # 思考卷积神经网络(CNN)中各种意义 _只是知道CNN是不够,我们需要对其进行解剖,继而分析不同部件存在的意义_ ## CNN的目的 简单来说,CNN的目的是以一定的模型对事物进行特征提取,而后根据特征对该事物进行分类、识别、预测或决策等。在这个过程里,最重要的步骤在于特征提取,即如何提取到能最大程度区分事物的特征。如果提取的特征无法将不同的事物进行划分,那么该特征提取步骤将毫无意义。而实现这个伟大的模型的,是对CNN进行迭代训练。 ### 特征 在图像中(举个例子),目标事物的特征主要体现在像素与像素之间的关系。比如说,我们能区分一张图片中有一条直线,是因为直线上的像素与直线外邻像素的区别足够大(或直线两边的像素区别足够大),以至于这“直线”能被识别出来: ![直线上的像素与直线外邻像素的区别足够大](https://source.mkshell.com/2016-09-02-14728207615074.jpg) ![直线两边的像素区别足够大](https://source.mkshell.com/2016-09-02-14728208045910.jpg) 除了直线外,其他特征也同理。在CNN中,大部分特征提取依赖于卷积运算。 ## 卷积与特征提取 卷积在此其实就是内积,步骤很简单,就是根据多个一定的权重(即卷积核),对一个块的像素进行内积运算,其输出就是提取的特征之一: ![](https://source.mkshell.com/2016-09-02-14728212916756.gif) ### 选用卷积的原因 #### 局部感知 简单来说,卷积核的大小一般小于输入图像的大小(如果等于则是全连接),因此卷积提取出的特征会更多地关注局部 —— 这很符合日常我们接触到的图像处理。而每个神经元其实没有必要对全局图像进行感知,只需要对局部进行感知,然后在更高层将局部的信息综合起来就得到了全局的信息。 #### 参数共享 参数共享最大的作用莫过于很大限度地减少运算量了。 #### 多核 一般我们都不会只用一个卷积核对输入图像进行过滤,因为一个核的参数是固定的,其提取的特征也会单一化。这就有点像是我们平时如何客观看待事物,必须要从多个角度分析事物,这样才能尽可能地避免对该事物产生偏见。我们也需要多个卷积核对输入图像进行卷积。 #### Down-Pooling 卷积后再接上一个池化层(Pooling)简直就是绝配,能很好的聚合特征、降维来减少运算量。 #### 多层卷积 层数越高,提取到的特征就越全局化。 ## 池化 池化,即对一块数据进行抽样或聚合,例如选择该区域的最大值(或平均值)取代该区域: ![](https://source.mkshell.com/2016-09-02-14728222809788.gif) 上图的池化例子,将10 \* 10的区域池化层1 \* 1的区域,这样使数据的敏感度大大降低,同时也在保留数据信息的基础上降低了数据的计算复杂度。 ## 激活函数的意义 在数学上,激活函数的作用就是将输入数据映射到0到1上(tanh是映射-1到+1上)。至于映射的原因,除了对数据进行正则化外,大概是控制数据,使其只在一定的范围内。当然也有另外细节作用,例如Sigmoid(tanh)中,能在激活的时候,更关注数据在零(或中心点)前后的细小变化,而忽略数据在极端时的变化,例如ReLU还有避免梯度消失的作用。通常,Sigmoid(tanh)多用于全连接层,而ReLU多用于卷积层。 ![](https://source.mkshell.com/2016-09-02-14728233650926.jpg) ![](https://source.mkshell.com/2016-09-02-14728233755870.jpg) 或者我们换一个卷积核(换一种角度)来看待这个激活函数,如果我们把每一次激活动作当成一次分类,即对输入数据分成两类(0或1),那么激活函数得到的输出是在0到1的值,它可以代表着这次“分类”的归属度。如果我们把0规定为未激活,1表示激活,那么输出0.44就表示激活了44%。 而激活函数的使用却有可能带来一定的负面影响(对于训练的负面影响),激活函数可能会使我们得输入数据都激活了大半,对此我们有另外的对策 —— LRN。 ## LRN的催化与抑制 LRN,局部响应归一化。在神经学科中,有一个叫横向抑制(lateral inhibition)的概念,这种抑制的作用就是阻止兴奋神经元向邻近神经元传播其动作趋势,从而减少兴奋神经元的邻近神经元的激活程度。借鉴了这一生物现象(其实我们全部东西都是借鉴生物的,不是?),我们使用LRN层来对激活函数的输出数据进行横向抑制,在为激活函数收拾残局的同时,LRN还凸显了该区域的一个峰值 —— 这个峰值特征就是我们想要的特征。 特别是ReLU,它无限制的激活使得我们更需要LRN来到数据进行归一化。在大规模的数据中,我们大多情况下更看重被凸显的高频特征。因此,用LRN对数据的峰值进行催化而对其周围进行抑制,何乐不为。 ## IP层 ![BP神经网络中d的全连接层](https://source.mkshell.com/2016-09-02-14728250710656.png) 在许多CNN的后部分,都存在着一个IP(Inner Product)层/内积层/fc(full connect)层/全连接层。这个全连接网络的代表性层级,其存在于CNN的意义我不得而知。在许多论文中,它替代了softmax来特征负责最终的提取,而有人也指出CNN最后可以不用IP层。 ## Dropout的舍弃 舍弃一直是一个伟大的哲学,生物的进化上例子比比皆是。Dropout的任务就是在训练时,通过设置阈值并与某些隐含层节点的权重对比,让特定的权重不工作 —— 在该层舍弃这些权重。Dropout的作用也很明显,除了加速运算外,就是防止过拟合了。 若有错误之处请指出,更多地关注[煎鱼](http://www.jianyujianyu.com)。 https://www.mkshell.com/posts/2016-08-05-writing-web-using-go/ https://www.mkshell.com/posts/2016-08-05-writing-web-using-go.md 2016-08-05 # 用Go写一个简单Web应用 ## 简介和要求 教程将涵盖以下知识: - 创建一个带有load和save方法的数据结构体 - 使用net/http包来创建web应用 - 使用html/template包来处理HTML模板 - 使用regexp包来检测用户的输入 - 使用闭包 假设你已经有以下知识: - 编程经验 - 明白基础的web知识(HTTP,HTML) - 具有些UNIX、DOS的命令行知识 ## 开始 首先,你需要有一个装了FreeBSD、Linux、MacOS甚至是Windows系统的机器来跑Go程序。然后,你需要有一个Go环境,即你需要安装Go(废话)。 为了这个教程,我们先在GOPATH里创建一个文件夹,并用cd命令进入里面: ``` $ mkdir gowiki $ cd gowiki ``` 而后创建一个名为wiki.go的文件,用你最中意的编辑器打开它,并在里面加上以下内容: ``` package main import ( "fmt" "io/ioutil" ) ``` 以上我们从Go的标准库中导入了fmt和ioutil包。而之后我们会用import声明来引入更多的包,如果我们要实现更多额外的功能的话。 ## 数据结构体 现在我们开始定义一个结构体。想象一下,一个wiki应该有什么?它应该有一堆互有关系(互含链接)的网页Page,而后每个网页Page都起码有一个标题Title和主体Body,主体就是内容。因此我们先定义一个网页Page的结构,它有一个标题域和主体域: ``` type Page struct { Title string Body []byte } ``` 其中\[\]byte是“比特切片”的意思,不选用string而选用\[\]byte,是因为我们会在io库中用到\[\]byte类型,接下来你会看到的。 Page结构体描述了网页的数据存储在内存中的形式,然而持久化存储呢?我们可以为Page创建一个save方法来实现存储: ``` func (p *Page) save() error { filename := p.Title + ".txt" return ioutil.WriteFile(filename, p.Body, 0600) } ``` 这个方法的声明`func (p *Page) save() error{}`表明了这个方法名为save,属于p,即Page指针类型可调用的方法,而且它不需要传入任何参数,而后返回一个error类型的值。 这个方法会将网页的主体保存到一个txt文件中,而方便起见,我们选用这个网页的Title项作为这个txt文件的名字。而这个save方法返回一个error类型是因为error也是WriteFile的返回类型(WriteFile是一个用于将\[\]byte写入文件的标准库函数),当然这样返回error也是为了能让程序在写文件时发生错误后处理好。而大多数情况是一切安好,没有故障,这样Page.save()就会返回一个nil(nil是指针、接口或其他类型的”零值“)。 如果你足够细心的话,你会发现WriteFile的第三个参数很奇怪。这个一个八进制数字0600,表明了这个txt文件只对于当前用户,是以具有可读写权限的方式打开的(p.s. 不懂为啥是600的可以看一下Unix/Linux的权限的表达方式)。 有了存储save()方法,对应的也应该有读取的方法: ``` func loadPage(title string) *Page { filename := title + ".txt" body, _ := ioutil.ReadFile(filename) return &Page{Title: title, Body: body} } ``` loadPage的思想很简单:读取与title有相应名字的txt文件,将文件的内容赋值给变量body,而后返回一个带有Title和Body的Page结构体实例的指针。 首先我们得知道函数是可以返回多个返回值的。而标准库函数io.ReadFile返回了\[\]byte和error两个类型的值。当然这个简单的loadPage,并没有对错误进行处理 —— 上述代码使用了空标识符,即下划线\_,用于抛弃这个error返回值(本质上,这样就是将变量分配到空,就是抛弃)。 但是问题很严重 —— 如果有error了应该怎么办?举个例子,可能这个txt文件并不存在,总不能抛弃吧。因此我们不能坐视不理,我们应该修改一下这个方法: ``` func loadPage(title string) (*Page, error) { filename := title + ".txt" body, err := ioutil.ReadFile(filename) if err != nil { return nil, err } return &Page{Title: title, Body: body}, nil } ``` 现在这个方法的调用者就可以检测一下第二返回值是否为空,以此来检测是否成功读取到Page了。 而在这个moment,我们已经有一些简单的数据结构和读取页面的方法了。我们可以着手于写一个main来测试一下我们写的有没有错误: ``` func main() { p1 := &Page{Title: "TestPage", Body: []byte("This is a sample Page.")} p1.save() p2, _ := loadPage("TestPage") fmt.Println(string(p2.Body)) } ``` 以上代码编译和执行完后,一个名为TestPage.txt、包含着p1内容的文件将会被创建出来。而后这个文件会被p2这个结构体读取,而后这个p2结构体的Body内容将会被输出到屏幕上。 好,现在我们可以编译和运行了: ``` $ go build wiki.go $ ./wiki This is a sample page. ``` (如果你是windows上跑的,运行的时候直接打"wiki"就行,不需要“./”了。) ## 介绍一下net/http包 以下是一个简单的web服务器程序例子: ``` package main import ( "fmt" "net/http" ) func handler(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "Hi there, I love %s!", r.URL.Path[1:]) } func main() { http.HandleFunc("/", handler) http.ListenAndServe(":8080", nil) } ``` main函数一开始就调用了http.HandleFunc,这个函数就是告诉http包,将web服务器所有/下的请求交给handler处理。接着main函数又调用了http.ListenAndServe,目的就是指定8080端口(先不用管那个第二个参数nil),这个ListenAndServe会一直阻塞整个程序,直到程序被终止。 函数handler的类型是http.HandlerFunc,它需要两个参数,http.ResponseWriter和http.Request。http.ResponseWriter变量的作用就是回应HTTP请求,向它写入一些东西即向HTTP客户端发送一些东西。而http.Request是一个代表了HTTP客户请求的数据结构。handler中的r.URL.Path是请求URL的路径部分,紧跟着的\[1:\]是对路径的一个切片动作,即返回r.URL.Path的第一个字符到结尾的切片,在这里,实际意味着除去/符号,只要后面的。 如果你运行该程序,并访问以下地址: ``` http://localhost:8080/monkeys ``` 这个程序就会在终端中输出: ``` Hi there, I love monkeys! ``` ## 将wiki和net/http包合并使用 想要使用net/http包,我们必须先将它导入: ``` import ( "fmt" "io/ioutil" "net/http" ) ``` 而后我们创建一个handler,叫做viewHandler,它可允许用户访问wiki的页面,它会处理前缀是/view/的URL。 ``` func viewHandler(w http.ResponseWriter, r *http.Request) { title := r.URL.Path[len("/view/"):] p, _ := loadPage(title) fmt.Fprintf(w, "

%s

%s
", p.Title, p.Body) } ``` 首先,函数先在请求URL的路径部分,即r.URL.Path中提取页面的title。路径被\[len("/view/"):\]重新切片了一下,为的就是除掉请求URL中/view/的部分,这是因为/view/并不是page的名称。而后函数读取这个txt文件,然后将内容格式转换为的string形式的简单的HTML,继而将这个string写到w,即http.ResponseWriter。 同样这里需要注意一下这个*,这里的*也是直接忽略了loadPage的error返回值,这样做仅是为了程序的简单易懂,而这样忽略通常是极其不好的习惯。我们迟点再对这里进行修改(其实,很多时候都是说迟点再改然后就忘了- -)。 要使用这个handler,我们需要重写一下我们的main函数中的http初始化部分: ``` func main() { http.HandleFunc("/view/", viewHandler) http.ListenAndServe(":8080", nil) } ``` 然后我们手动创建一下txt文件(比如说test.txt,用编辑器在里面写句Hello world),然后编译一下我们的代码并运行。 ``` $ go build wiki.go $ ./wiki ``` 接着你就可以在浏览器访问http://localhost:8080/view/test看看是不是有一个Hello world,或者是你自己自定义的启动txt文件内容。 ## wiki的编辑页面 不能编辑wiki页面的wiki不是一个好wiki。因此,我们需要额外创建两个handler,一个叫做editHandler,用来显示一个wiki的编辑页面,另一个handler叫saveHandler,用来通过提交的表单保存 数据。 首先,我们先将一下这些添加到main: ``` func main() { http.HandleFunc("/view/", viewHandler) http.HandleFunc("/edit/", editHandler) http.HandleFunc("/save/", saveHandler) http.ListenAndServe(":8080", nil) } ``` 而后以下的函数editHandler将会载入页面(或者如果这个页面不存在的话,创建一个新的页面结构),然后在浏览器显示这个编辑的表单: ``` func editHandler(w http.ResponseWriter, r *http.Request) { title := r.URL.Path[len("/edit/"):] p, err := loadPage(title) if err != nil { p = &Page{Title: title} } fmt.Fprintf(w, "

Editing %s

"+ "
"+ "
"+ ""+ "
", p.Title, p.Title, p.Body) } ``` 这样的代码是没有问题的,它能正常运行,但是,这样的硬编码HTML实在是太难看了,我们需要一种“优雅”的输出HTML的方式。 ## 使用html/template包优雅地输出HTML html/template包属于Go标准库。使用这个包,我们就可以分离HTML到一个单独的HTML文件,这样我们在修改编辑页面的布局时,就不用再Go代码里面修改了。 首先,我们还是先在import列表中加入我们要导入的html/template,同时,这一次我们不再想要用fmt将内容输出到屏幕了,我们将fmt去掉: ``` import ( "html/template" "io/ioutil" "net/http" ) ``` 接下来,我们创建一个包含了HTML表单的模板 —— 新建一个名为edit.html的文件并添加以下内容: ```

Editing {{.Title}}

``` 修改editHandler,将硬编码HTML替换为使用模板: ``` func editHandler(w http.ResponseWriter, r *http.Request) { title := r.URL.Path[len("/edit/"):] p, err := loadPage(title) if err != nil { p = &Page{Title: title} } t, _ := template.ParseFiles("edit.html") t.Execute(w, p) } ``` template.ParseFiles函数会读取edit.html内容,并返回一个模板的指针\*template.Template。而t.Execute函数则是将内容写入模板而后生成HTML到http.ResponseWriter。模板中.Title和.Body中使用的点就是指传入的对象的对应属性,在这里指p.Title和p.Body。 模板中的变量要使用双花括号套住。而模板中的指令`printf "%s" .Body`,则是一个以string形式而不是bytes串形式输出Body值的函数调用,这样就相当于fmt.Printf函数,只是一个是输出于浏览器页面上,另一个输出于终端上。 html/template包可以帮我们通过模板生成安全的、正确的HTML代码。比如说,它能自动帮你转换大于号(>),就是自动地帮你替换`>`,这样就保证用户数据不会对HTML表单造成干扰。 既然我们已经在使用模板了,我们就继续用模板的这个套路来创建我们的view.html以及调用它的viewHandler。 ```

{{.Title}}

[edit]

{{printf "%s" .Body}}
``` 修改viewHandler: ``` func viewHandler(w http.ResponseWriter, r *http.Request) { title := r.URL.Path[len("/view/"):] p, _ := loadPage(title) t, _ := template.ParseFiles("view.html") t.Execute(w, p) } ``` 好,该重点细节的时候来了。你看,我们两个handler都有相同的代码来渲染HTML,一个合格的程序员看到这种情况应该要将重复的部分模块化: ``` func renderTemplate(w http.ResponseWriter, tmpl string, p *Page) { t, _ := template.ParseFiles(tmpl + ".html") t.Execute(w, p) } ``` 然后两个handler就变成: ``` func viewHandler(w http.ResponseWriter, r *http.Request) { title := r.URL.Path[len("/view/"):] p, _ := loadPage(title) renderTemplate(w, "view", p) } func editHandler(w http.ResponseWriter, r *http.Request) { title := r.URL.Path[len("/edit/"):] p, err := loadPage(title) if err != nil { p = &Page{Title: title} } renderTemplate(w, "edit", p) } ``` 不要觉得代码反而变长了就不重要,甚至嘲笑,重要的是思想:) ## 处理好不存在的页面 如果你去访问/view/APageThatDoesntExist这样一个不存在的页面(假设他不存在),会发生什么?如果是上述代码的Web应用,你应该会看到一个包含着HTML的页面,这是因为我们在loadPage中忽略了文件不存在时的错误处理(这事终于被提上日程了),于是这样地读文件,它只读了个空。其实,如果请求的页面不存在,这样的请求就应该被重定向到编辑页面,这样用户就可以创建一个新的文件了。 ``` func viewHandler(w http.ResponseWriter, r *http.Request) { title := r.URL.Path[len("/view/"):] p, err := loadPage(title) if err != nil { http.Redirect(w, r, "/edit/"+title, http.StatusFound) return } renderTemplate(w, "view", p) } ``` http.Redirect函数参数包含了一个状态码http.StatusFound (302),以及一个响应的目标地址。 ## 保存页面 函数saveHandler将会处理好编辑页面中表单的子任务: ``` func saveHandler(w http.ResponseWriter, r *http.Request) { title := r.URL.Path[len("/save/"):] body := r.FormValue("body") p := &Page{Title: title, Body: []byte(body)} p.save() http.Redirect(w, r, "/view/"+title, http.StatusFound) } ``` 页面标题(由URL提供)以及Body都会被保存在新的Page实例中。然后save()函数的调用就是要将数据写入到一个文件中,然后客户端就被重定向到/view/中对应的页面。 其中函数FormValue()的返回值是string类型,我们需要将它转换为\[\]byte类型,这样才能用它来生成新的Page结构体实例。我们使用`[]byte(body)`来实现这个转换。 ## 错误处理 我们的程序有几处地方忽略了不该忽略的错误,这是一个坏习惯,尤其是程序发生了你意想不到的错误的时候,你更会后悔莫及。一个较好的处理方案就是先程序自行处理好错误,然后将错误信息发送给用户。按照这样的方案,我们的应用必然会和我们预料的一样执行,并在有问题的时候通知用户。 首先,我们需要在我们刚才分离出来的renderTemplate里面添加处理信息的代码(模块化的好处开始凸显): ``` func renderTemplate(w http.ResponseWriter, tmpl string, p *Page) { t, err := template.ParseFiles(tmpl + ".html") if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } err = t.Execute(w, p) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } ``` http.Error函数会给用户发送一个特定的状态码(这里是Internal Server Error)和错误信息。然后我们继续以同样的方法修改saveHandler: ``` func saveHandler(w http.ResponseWriter, r *http.Request) { title := r.URL.Path[len("/save/"):] body := r.FormValue("body") p := &Page{Title: title, Body: []byte(body)} err := p.save() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, "/view/"+title, http.StatusFound) } ``` 当p.save()执行时发生了错误,就会调用http.Error,用户就会得知此错误信息。 ## 模板的缓存 当然这个简单的web应用还是不够高效,怎么个不够高效法呢?就是没有缓存,每次用户查看页面,Go程序都需要读取数据并重新渲染一下模板。也就是说,renderTemplate每次都调用ParseFiles。我们对其进行优化,比较好的方案是在程序初始化的时候调用ParseFiles一次,然后将所有模板都赋值到一个\*Template里面,我们之后就用ExecuteTemplate来渲染特定的模板了。 首先我们先用ParseFiles函数初始化一个全局变量,名为templates: ``` var templates = template.Must(template.ParseFiles("edit.html", "view.html")) ``` template.Must是个封装好的函数,它会在Parse返回err不为nil时,调用panic,而其他情况下,它会照常返回Template指针。Must的引入允许我们不用显式地处理错误,而我们只关注这个业务功能而忽略它还会返回一个错误。而panic在这里也用得较为合适 —— 如果模板都不能被加载了,那么能做得好的事就只能是退出程序了。 ParseFiles函数的参数是任意数量的string,这些string对应着我们的模板文件。然后这些模板都会按照文件的命名加载到templates中。当然如果我们要添加更多的模板文件,我们只需要将它们的名字加到ParseFiles的参数里面。 然后我们正式地对renderTemplate进行修改,改成调用templates.ExecuteTemplate的形式: ``` func renderTemplate(w http.ResponseWriter, tmpl string, p *Page) { err := templates.ExecuteTemplate(w, tmpl+".html", p) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } ``` p.s.记得加上.html哦。 ## 验证 其实如果你足够细心,你会发现一个安全隐患问题:用户可以在服务器应用下的任意路径做读取和修改。为了尽可能避免这个安全隐患,我们写一个函数,它可以用正则表达式来验证title的合法性。 首先我们先将regexp加入到import列表中,然后我们需要创建一个全局变量来存储我们需要用到的表达式: ``` var validPath = regexp.MustCompile("^/(edit|save|view)/([a-zA-Z0-9]+)$") ``` 在这里,我们看到了熟悉的身影Must:regexp.MustCompile,它负责转换和遍历正则表达式,然后返回一个regexp.Regexp类型的变量。类似的,MustCompile和Compile这个妖艳贱货不一样,Must在处理失败的时候会返回一个panic。 然后,我们可以开始写一个函数,它专门用于验证validPath的正确性并从中提取文件的title: ``` func getTitle(w http.ResponseWriter, r *http.Request) (string, error) { m := validPath.FindStringSubmatch(r.URL.Path) if m == nil { http.NotFound(w, r) return "", errors.New("Invalid Page Title") } return m[2], nil // The title is the second subexpression. } ``` 如果路径合法,它会带着一个nil值返回(第二个返回值);如果路径不合法,函数就会将“404 Not Found”写进HTTP连接,然后给handler返回一个error。当然,为了可以新建error,我们需要import一下errors包。 然后我们修改所有handler,让它们在里面调用getTitle来验证合法性: ``` func viewHandler(w http.ResponseWriter, r *http.Request) { title, err := getTitle(w, r) if err != nil { return } p, err := loadPage(title) if err != nil { http.Redirect(w, r, "/edit/"+title, http.StatusFound) return } renderTemplate(w, "view", p) } func editHandler(w http.ResponseWriter, r *http.Request) { title, err := getTitle(w, r) if err != nil { return } p, err := loadPage(title) if err != nil { p = &Page{Title: title} } renderTemplate(w, "edit", p) } func saveHandler(w http.ResponseWriter, r *http.Request) { title, err := getTitle(w, r) if err != nil { return } body := r.FormValue("body") p := &Page{Title: title, Body: []byte(body)} err = p.save() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, "/view/"+title, http.StatusFound) } ``` ## 闭包 心细的你又再次发现,为了捕捉和处理各种错误,我们使用了很多代码,而且还是很多重复的代码,那么我们能不能做点什么呢?也许抽象函数能帮得到我们。 首先,我们重写一下handler的函数定义(以同样的形式,整齐的参数): ``` func viewHandler(w http.ResponseWriter, r *http.Request, title string) func editHandler(w http.ResponseWriter, r *http.Request, title string) func saveHandler(w http.ResponseWriter, r *http.Request, title string) ``` 接下来我们定义一个包装函数,它需要的参数类型是*上面我们声明的函数类型*,即`func (http.ResponseWriter, *http.Request, string)`,然后返回一个http.HandlerFunc类型的函数,这个包装函数的大致框架如下: ``` func makeHandler(fn func (http.ResponseWriter, *http.Request, string)) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // Here we will extract the page title from the Request, // and call the provided handler 'fn' } } ``` 上述函数return了一个函数自己内部定义的函数,称为闭包。在这里,变量fn(即makeHandler唯一的参数)被闭包直接用了,而fn可以是我们的view、save或者edit的handler。 然后我们可以开始着手于闭包里面的实现,我们可以借用getTitle的代码: ``` func makeHandler(fn func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { m := validPath.FindStringSubmatch(r.URL.Path) if m == nil { http.NotFound(w, r) return } fn(w, r, m[2]) } } ``` makeHandler返回的闭包是带有http.ResponseWriter和http.Request参数的函数。而在这里,闭包在请求路径中提取了title,然后使用TitleValidator正则来检验合法性。如果不合法,它将会向ResponseWriter 用http.NotFound函数写入一个error;如果合法,函数将会用ResponseWriter、Request和title三个参数来调用fn。 然后我们就可以在main函数里面包装我们的handler了: ``` func main() { http.HandleFunc("/view/", makeHandler(viewHandler)) http.HandleFunc("/edit/", makeHandler(editHandler)) http.HandleFunc("/save/", makeHandler(saveHandler)) http.ListenAndServe(":8080", nil) } ``` 然后我们去掉先前的getTitle,让handler变得苗条点: ``` func viewHandler(w http.ResponseWriter, r *http.Request, title string) { p, err := loadPage(title) if err != nil { http.Redirect(w, r, "/edit/"+title, http.StatusFound) return } renderTemplate(w, "view", p) } func editHandler(w http.ResponseWriter, r *http.Request, title string) { p, err := loadPage(title) if err != nil { p = &Page{Title: title} } renderTemplate(w, "edit", p) } func saveHandler(w http.ResponseWriter, r *http.Request, title string) { body := r.FormValue("body") p := &Page{Title: title, Body: []byte(body)} err := p.save() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, "/view/"+title, http.StatusFound) } ``` ## 最终再试一下 重新编译运行试试? ``` $ go build wiki.go $ ./wiki ``` 试试运行访问http://localhost:8080/view/ANewPage,看看会怎么样?它应该会给出一个新的编辑页面,然后你可以输入一些内容,你可以保存,在保存后它会将你重定向到新的wiki页面~ ### P.S.如果你很混乱,那么我附上最终的全部代码? ``` // Copyright 2010 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package main import ( "html/template" "io/ioutil" "net/http" "regexp" ) type Page struct { Title string Body []byte } func (p *Page) save() error { filename := p.Title + ".txt" return ioutil.WriteFile(filename, p.Body, 0600) } func loadPage(title string) (*Page, error) { filename := title + ".txt" body, err := ioutil.ReadFile(filename) if err != nil { return nil, err } return &Page{Title: title, Body: body}, nil } func viewHandler(w http.ResponseWriter, r *http.Request, title string) { p, err := loadPage(title) if err != nil { http.Redirect(w, r, "/edit/"+title, http.StatusFound) return } renderTemplate(w, "view", p) } func editHandler(w http.ResponseWriter, r *http.Request, title string) { p, err := loadPage(title) if err != nil { p = &Page{Title: title} } renderTemplate(w, "edit", p) } func saveHandler(w http.ResponseWriter, r *http.Request, title string) { body := r.FormValue("body") p := &Page{Title: title, Body: []byte(body)} err := p.save() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } http.Redirect(w, r, "/view/"+title, http.StatusFound) } var templates = template.Must(template.ParseFiles("edit.html", "view.html")) func renderTemplate(w http.ResponseWriter, tmpl string, p *Page) { err := templates.ExecuteTemplate(w, tmpl+".html", p) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } } var validPath = regexp.MustCompile("^/(edit|save|view)/([a-zA-Z0-9]+)$") func makeHandler(fn func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { m := validPath.FindStringSubmatch(r.URL.Path) if m == nil { http.NotFound(w, r) return } fn(w, r, m[2]) } } func main() { http.HandleFunc("/view/", makeHandler(viewHandler)) http.HandleFunc("/edit/", makeHandler(editHandler)) http.HandleFunc("/save/", makeHandler(saveHandler)) http.ListenAndServe(":8080", nil) } ``` 若有错误之处请指出,更多地关注[煎鱼](http://www.jianyujianyu.com)。
https://www.mkshell.com/posts/2016-06-01-talking-about-nlp-problems/ https://www.mkshell.com/posts/2016-06-01-talking-about-nlp-problems.md 2016-06-01 # 谈谈NLP中的终极问题 _读了点书,写点笔记_ ## 歧义消除 分析特定的上下文的词被赋予何种意思。需要联系上下文,利用临近的词汇的相近含义。NLP中出现歧义的原因较多是因为是同一句句子可被分割为不同的结构,而不同的结构又能译出不同的意思。举个例子,如“咬死了猎人的狗”,“南京市长江大桥”等等,这些句子都能根据不同的结构分成不止一种的歧义。煎鱼所知道消除歧义的方法就两种(毕竟煎鱼读的书少),一是根据统计计算出跟普遍的用法,二则是将问题反问与用户。 第一种的话,也不需要太多地阐述,通俗点来讲就是看看这些歧义中,哪一种会被更多人接受。如“南京市长江大桥”,更多人认可是“南京市-长江-大桥”,而不是“南京-市长-江大桥”(当然煎鱼是讲得太过通俗了,这里的筛选其实是有严格的数据做支撑的)。 而第二种做法是煎鱼更为认同的,其实别说机器了,当我们人类遇到歧义时也是一脸懵逼的,所以当人类都不知道应该是哪一个意思的时候,为何要为难机器在这两个中选一个呢?更何况,概率小者也不该被忽略!而第二种做法就是:将问题反问给用户 —— 请问你说的是“南京-市长-江大桥”还是“南京市-长江-大桥”呢? ## 指代消除 通常需要分清某代词是指前文的主语还是宾语,例如“Fafa偷了煎鱼送给女朋友的项链,最后被警察捉了。”、“Fafa偷了煎鱼送给女朋友的项链,最后还是被归还了。”,究竟是哪个被警察捉,哪个被归还呢?这样的指代歧义想要消除就必须要理解前一句话整句话的意思 —— 这就有意思了,想要理解一句话,就要理解它前一句话的意思对吧(为啥我会想到0.5的无数次平方永远大于零)。 指代消除的一种做法是深度学习消除,可以参考《基于Deep Learning的代词指代消解》(奚雪峰,周国栋,2013.06),这里不作描述。 ## 自动生成语言 主要在自动回答和机器翻译中常用,依赖于对文意的理解以及机器对人类语言结构的掌握。煎鱼个人觉得两个都是难点,理解文意就不用说了,一直是难点;而语言结构为啥说也很难呢,主要是人类这种神奇的东西,ta可以通过人类语言中原本有限的语法规则,组合成口语中无限的可能,你看看啥事宾语前置、状语后置、X语X置什么鬼的。当然你也可以说,过于口语的语法不给机器用就行了 —— 这样的确可以,但是不会自动学习新语法规则的人工智能是人工智能吗? 同时,机器也要掌握一定的语言风格,当然也可以模仿别的语言者(语言风格包含了口头禅、喜欢用的词语搭配、是否有文采等)。 ## 机器翻译 机器翻译一直是语言理解的圣杯,它几乎是NLP的起源。可以说,机器翻译不仅要求机器充分理解句子的含义,而且还必须掌握了两门语言(甚至是历史文化)的异同。这样的工作,现在要求信雅达的翻译也得用人工,机器这是做不来!到现在,即使是大家大户的微软、谷歌等都没有一个较为完美的解决办法,可以说,它的解决方法是全人类都在寻找的(BTW,这个算是NP完全问题么)。 ## 人机对话系统 煎鱼第一想到的就是这名的图灵测试了 —— 究竟机器与真人两者同时和你聊天,你最后是否真的能区分出哪个是人类,哪个是机器呢? 当然,广义的人机对话即是人机交互了(煎鱼的理解)。人机对话可通过多种形式进行,有字符、手势、语音、表情、肢体动作等。其中,比较热门的语音的交互形式包含了语音识别和句意解释,而语音识别在现在也已经比较成熟了(微信开发都已经标配了语音识别了,有兴趣的加煎鱼的小小机器人,然后用语音尝试一下)。而人脸识别也是较为成熟。。吧。 ## NLP流程 ![](https://source.mkshell.com/2016-06-02-14647820410821.jpg) ![](https://source.mkshell.com/2016-06-02-14647820553120.jpg) 其中涉及:发音模型语位学(Phonology),形态规划形态学(Morphology),词汇和语法句法(Syntax),话语背景语义(Semantics),领域知识推理(Reasoning)。 若有错误之处请指出,更多地关注[煎鱼](http://www.jianyujianyu.com)。 https://www.mkshell.com/posts/2016-05-14-learing-svm/ https://www.mkshell.com/posts/2016-05-14-learing-svm.md 2016-05-14 # 如何理解SVM | 支持向量机之我见 **囫囵吞枣看完SVM,个人感觉如果不好好理解一些概念,或说如果知其然而不知其所以然的话,不如不看。因此我想随便写一写,把整个思路简单地整理一遍。:)** ## SVM与神经网络 支持向量机并不是神经网络,这两个完全是两条不一样的路吧。不过详细来说,线性SVM的计算部分就像一个单层的神经网络一样,而非线性SVM就完全和神经网络不一样了(是的没错,现实生活中大多问题是非线性的),详情可以参考[知乎答案](https://www.zhihu.com/question/22290096)。 这两个冤家一直不争上下,最近基于神经网络的深度学习因为AlphaGo等热门时事,促使神经网络的热度达到了空前最高。毕竟,深度学习那样的多层隐含层的结构,犹如一个黑盒子,一个学习能力极强的潘多拉盒子。有人或许就觉得这就是我们真正的神经网络,我们不知道它那数以百千计的神经元干了什么,也不理解为何如此的结构能诞生如此美好的数据 —— 犹如复杂性科学般,处于高层的我们并不能知道底层的”愚群“为何能涌现。两者一比起来,SVM似乎也没有深度学习等那么令人狂热,连Hinton都开玩笑说SVM不过是浅度学习(来自深度学习的调侃)。 不然,个人觉得相对于热衷于隐含层的神经网络,具有深厚的数学理论的SVM更值得让我们研究。SVM背后伟大的数学理论基础可以说是现今人类的伟大数学成就,因此SVM的解释性也非神经网络可比,可以说,它的数学理论让它充满了理性,这样的理性是一个理工科生向往的。就如,你渴望知道食物的来源以确定食物是否有毒,如果有毒是什么毒,这样的毒会在人体内发生了什么反应以致于让你不适 —— 我的理性驱使我这么想,一个来路不明的食物是不能让我轻易接受的。 ## SVM是什么 简单点讲,SVM就是个分类器,它用于回归的时候称为SVR(Support Vector Regression),SVM和SVR本质上都一样。下图就是SVM分类: ![](https://source.mkshell.com/2016-05-14-14632059577714.jpg) (边界上的点就是支持向量,这些点很关键,这也是”支持向量机“命名的由来) **SVM的目的:寻找到一个超平面使样本分成两类,并且间隔最大。而我们求得的w就代表着我们需要寻找的超平面的系数。** 用数学语言描述: ![](https://source.mkshell.com/2016-05-14-14632106164393.jpg) 这就是SVM的基本型。 SVM的基本型在运筹学里面属于二次规划问题,而且是凸二次规划问题(convex quadratic programming)。 ## 二次规划 二次规划的问题主要用于求最优化的问题,从SVM的求解公式也很容易看出来,我们的确要求最优解。 简介: 在限制条件为 ![](https://source.mkshell.com/2016-05-14-14632121860594.png) 的条件下,找一个n 维的向量 x ,使得 ![](https://source.mkshell.com/2016-05-14-14632122006481.png) 为最小。 其中,c为n 维的向量,Q为n × n 维的对称矩阵,A为m × n 维的矩阵,b为m 维的向量。 其中,根据优化理论,如果要到达最优的话,就要符合KKT条件(Karush-Kuhn-Tucker)。 ## KKT KKT是在满足一些有规则的条件下,一个非线性规则问题能有最优解的一个充分必要条件。也就是说,只要约束条件按照这个KKT给出的规则列出,然后符合KKT条件的,就可以有最优解。这是一个广义化拉格朗日乘数的成果。 把所有的不等式约束、等式约束和目标函数全部写为一个式子 `L(a, b, x)= f(x) + a*g(x)+b*h(x)`    KKT条件是说最优值必须满足以下条件: - L(a, b, x)对x求导为零 - h(x) = 0 - a\*g(x) = 0 ## 对偶问题 将一个原始问题转换为一个对偶问题,懂的人知道对偶问题不过是把原始问题换了一种问法,从另一角度来求问题的解,其本质上是一样的。就好像我不能证明我比百分之五的人丑,但是我能证明我比百分之九十五的人帅,那样就够了。那么,为啥要用对偶问题,直接求原始问题不好吗?参考一下[为什么我们要考虑线性规划的对偶问题?](https://www.zhihu.com/question/26658861/answer/53394624) 而二次规划的对偶问题也是二次规划,性质、解法和原来一样,所以请放心。(只做简要介绍 最后训练完成时,大部分的训练样本都不需要保留,最终只会保留支持向量。这一点我们从图上也能看得出来,我们要确定的超平面只和支持向量有关不是吗? ![](https://source.mkshell.com/2016-05-14-14632134952575.jpg) (你看,只和支持向量有关) 然而,问题又出现了(新解法的出现总是因为新问题的出现),对于SVM的对偶问题,通过二次规划算法来求解的计算规模和训练样本成正比,开销太大。换句话来说,输入数据小的时候还好,不过小数据几乎没啥用,但是数据量大起来又计算量太大,所以就得寻找一种适合数据量大而且计算量小的解法,这个就是SMO。 ## SMO SMO,Sequential Minimal Optimization,针对SVM对偶问题本身的特性研究出的算法,能有效地提高计算的效率。SMO的思想也很简单:固定欲求的参数之外的所有参数,然后求出欲求的参数。 例如,以下是最终求得的分类函数,也就是我们SVM的目标: ![](https://source.mkshell.com/2016-05-14-14632140318075.jpg) SMO算法每次迭代只选出两个分量ai和aj进行调整,其它分量则保持固定不变,在得到解ai和aj之后,再用ai和aj改进其它分量。 如何高效也能通过SMO算法的思想看得出来 —— 固定其他参数后,仅优化两个参数,比起之前优化多个参数的情况,确实高效了。然而,与通常的分解算法比较,它可能需要更多的迭代次数。不过每次迭代的计算量比较小,所以该算法表现出较好的快速收敛性,且不需要存储核矩阵,也没有矩阵运算。说白了,这样的问题用SMO算法更好。 ## 核函数 我们的SVM目的其实也简单,就是找一个超平面,引用一张图即可表述这个目的: ![](https://source.mkshell.com/2016-05-14-14632144374512.jpg) 然而现实任务中,原始样本空间也许并不能存在一个能正确划分出两类样本的超平面,而且这是很经常的事。你说说要是遇到这样的数据,怎么划分好呢: ![](https://source.mkshell.com/2016-05-14-14632143758259.jpg) 告诉我你的曲线方程吧,傻了吧~ 于是引入了一个新的概念:核函数。它可以将样本从原始空间映射到一个更高维的特质空间中,使得样本在这个新的高维空间中可以被线性划分为两类,即在空间内**线性划分**。这个过程可以观看[视频](https://www.youtube.com/watch?v=3liCbRZPrZA)感受感受,由于是youtube所以我截一下图: 这是原始数据和原始空间,明显有红蓝两类: ![](https://source.mkshell.com/2016-05-14-14632147909478.jpg) 通过核函数,将样本数据映射到更高维的空间(在这里,是二维映射到三维): ![](https://source.mkshell.com/2016-05-14-14632148908664.jpg) 而后进行切割: ![](https://source.mkshell.com/2016-05-14-14632149178576.jpg) 再将分割的超平面映射回去: ![](https://source.mkshell.com/2016-05-14-14632149709432.jpg) ![](https://source.mkshell.com/2016-05-14-14632149780244.jpg) 大功告成,这些就是核函数的目的。 再进一步,核函数的选择变成了支持向量机的最大变数(如果必须得用上核函数,即核化),因此选用什么样的核函数会影响最后的结果。而最常用的核函数有:线性核、多项式核、高斯核、拉普拉斯核、sigmoid核、通过核函数之间的线性组合或直积等运算得出的新核函数。(这里只涉及概念,不涉及数学原理) ## 软间隔 知道了上面的知识后,你不是就觉得SVM分类就应该是这样的: ![](https://source.mkshell.com/2016-05-14-14632153433430.jpg) 然而这也不一定是这样的,上图给出的是一种完美的情况,多么恰巧地两类分地很开,多么幸运地能有一个超平面能将两个类区分开来!要是这两个类有一部分掺在一起了,那又该怎么分啊: ![](https://source.mkshell.com/2016-05-14-14632157241594.jpg) 有时候如果你非要很明确地分类,那么结果就会像右边的一样 —— 过拟合。明显左边的两个都比过拟合好多了,可是这样就要求允许一些样本不在正确的类上,而且这样的样本越少越好,”站错队“的样本数量要通过实际来权衡。这就得用上”软间隔“,有软间隔必然有硬间隔,应间隔就是最开始的支持向量机,硬间隔支持向量机只能如此”明确“地分类。特意找来了这个数学解释: ![](https://source.mkshell.com/2016-05-14-14632160235765.jpg) 其中一个样本要是”站错队“就要有损失,我们的目的就是:找出总损失值最小并且能大概分类的超平面。而计算一个样本的损失的损失函数也有很多种,例如:hinge损失、指数损失、対率损失等。 只是简单地把思路整理了一遍而已。 若有错误之处请指出,更多地关注[煎鱼](http://www.jianyujianyu.com)。 https://www.mkshell.com/posts/2016-05-12-10k-parallel-on-raspberry2/ https://www.mkshell.com/posts/2016-05-12-10k-parallel-on-raspberry2.md 2016-05-12 # 在树莓派2上Nginx并发1W到底有多难 ## 道听途说后的感想 总能得知Nginx的最大并发量能去到10W!简直吓到我,也不知道是我弱鸡还是我没弄过,反正10W的并发量我是没有弄过出来。不过嘛,说不定一台好CPU、大内存、高IO、高优化的服务器随便就可以上十万了,怎么说也是XEON的CPU然后来个几百几十G的内存是吧。那么在性能那么低的树莓派上并发量又能去到多少呢?网友总会说用树莓派当服务器还是算了吧,就这烂性能。这我也深有体会,在上面搭建的MC服务器简直没法玩啊!好吧,毕竟只是个值一两百块钱的板子而已: - CPU:4核,性能大概只相当于196MHZ的i3(网友反映的) - 内存:1G,简直不够用好吧! - 硬盘IO:十几M每秒吧,其实就是SD卡的IO 你看看这性能: 1.根本不能指望SD的IO能干嘛,换句话来说,传统数据库的读写、SWAP的换出换入等和外存有关的都不能实现(这样会拖时间而且换入换出的时候CPU根本承受不了!) 2.因为上面的原因,几乎所有的东西都得堆在内存,1G的内存 3.CPU很low,超频了也不过是零点几、零点零几的提升,如果还不用多线程榨干它简直大逆不道 其实看到这配置估计就有很多人会说:欸,你这不是傻嘛,这么点可怜的性能怎么提升也不可能有什么效果的,还不如去用真·服务器去。其实我主要是不太想这个东西积尘了,也想折腾一下这方面的而已。 ## 除掉Raspbian的限制 ### 修改最大连接数 somaxconn echo 50000 > /proc/sys/net/core/somaxconn ### 加快tcp连接的回收 echo 1 > /proc/sys/net/ipv4/tcp_tw_recycle ### tcp重用 echo 1 > /proc/sys/net/ipv4/tcp_tw_reuse  ### 修改成不做洪水抵遇 echo 0 > /proc/sys/net/ipv4/tcp_syncookies ### 修改系统的打开文件数的限制 ulimit -n 50000 直接用脚本一次性写入即可(毕竟我更喜欢做个懒人) ![](https://source.mkshell.com/2016-05-13-14631237005719.jpg) p.s.其实上面的那个50000是有点夸张而已,树莓派那么低的性能。。 当然还有很多各种各样的参数,我也不做详细解读,例如: fs.file-max = 999999 net.ipv4.tcp_tw_reuse = 1 net.ipv4.tcp_keepalive_time = 600 net.ipv4.tcp_fin_timeout = 30 net.ipv4.tcp_max_tw_buckets = 5000 net.ipv4.ip_local_port_range = 1024    61000 net.ipv4.tcp_rmem = 4096 32768 262142 net.ipv4.tcp_wmem = 4096 32768 262142 net.core.netdev_max_backlog = 8096 net.core.rmem_default = 262144 net.core.wmem_default = 262144 net.core.rmem_max = 2097152 net.core.wmem_max = 2097152 net.ipv4.tcp_syncookies = 1 net.ipv4.tcp_max_syn.backlog=1024 参数值可以忽略。 ## 修改Nginx的配置 这一步本来是最重要的,但是由于性能的原因,也就只能尽量了。 ### 修改worker_processes并绑定CPU 尽量地多线程处理请求,并且在低性能CPU中一个worker绑定一个CPU,将切换worker的资源消耗最小化: ![](https://source.mkshell.com/2016-05-13-14631243899899.jpg) ### 修改worker_rlimit_nofile配置 也就是修改nginx的最高打开文件数 ### 修改worker_connections 修改Nginx的每个worker的最大连接数 ![](https://source.mkshell.com/2016-05-13-14631242372230.jpg) ### 负载均衡的上游服务器 开8个,用Python的Tornado(如果要连Tomcat的话,要自己调好参数,不然会拒绝大部分请求的,而且还有控制好tomcat不断飙升的占用内存,树莓派的1G承受不住!) ![](https://source.mkshell.com/2016-05-13-14631246342501.jpg) ## 后台搭建 已经说过了用Python的Tornado做后台,直接就可以用它的demo中的blog来测试,选用blog原因如下: - 具有前端模板的渲染 - 具有后台数据的提取 - 具有登录鉴权系统 这样的后台,虽然小并且简陋,但是与大部分实际应用相类似,因此选用。而且也特意不用缓存,这样子直接测它的性能。blog大概就是这个样子的:(其实挺丑的,测试数据请忽略) ![](https://source.mkshell.com/2016-05-13-14631248722938.jpg) ## 使用ApacheBench测试 接下来就是使用ab测试了,而且是用树莓派本身来发出请求测试,这样可以忽略网络的作用(其实是因为我手头上没有linux设备,Mac测试浮动太大- -)。 ab -c 8000 -n 50000  http://127.0.0.1/ (根本不敢用1万的并发量好吧!) 结果如图: ![](https://source.mkshell.com/2016-05-13-14631251132157.jpg) 可以看出来: - 5万请求中有一千多的失败(对于这样的“服务器”已经很好了!!) - 用时好慢 - 看到Processing和Waiting的时间直接人都崩溃了好吗 可以看得出来树莓派不能干这事(怎么感觉只是在自己打自己脸而已),别说1万,8000或者5000都难,所以就不要用树莓派来做服务器啦~(搭建集群的土豪请忽略) 若有错误之处请指出,更多地关注[煎鱼](http://www.jianyujianyu.com)。 https://www.mkshell.com/posts/1995-07-31-arichtect-learning-concept/ https://www.mkshell.com/posts/1995-07-31-arichtect-learning-concept.md 1995-07-31 # 学架构笔记1:架构的概念 _学习笔记,较为简略。_ 理解并分清概念的方法最好是选出容易混淆并区分,而且必须要吹毛求疵的那种。 ## 系统与子系统 ### 系统 > 系统泛指由一群有关联的个体组成,根据某种规则运作,能完成个别元件不能单独完成的工作的群体。它的意思是“总体”、“整体”和“联盟”。 关键词:关联、规则、能力 ### 子系统 > 子系统也是由一群有关联的个体所组成的系统,多半会是更大的系统中的一部分。 子系统的定义与系统类似,只是在观察角度略有区别。 ### 举例说明 1. 微信是一个系统,有聊天、支付、票圈等子系统 2. 票圈这个系统又有动态、评论等子系统 3. 评论这个系统又有防刷、审核、发布、存储子系统 4. 评论审核子系统不再包含业务意义上的子系统,而是模块或组件。 ## 模块与组件 这两个概念容易混淆。 模块和组件都是系统的组成部分,只是从不同的角度拆分系统。 - 模块:从逻辑的角度拆分,目的是分离职责 - 组件:从物理的角度拆分,目的是单元复用 ### 举例说明 学生信息管理系统: - 模块角度:登录注册模块、个人信息模块、个人成绩模块 - 组件角度:Nginx、Web server、MySQL ## 框架与架构 ### 框架 > 软件框架通常指的是为了实现某个业界标准或完成特定基本任务的软件组件规范,也指为了实现某个软件组件规范时,提供规范所要求之基础功能的软件产品。 1. 框架是组件规范 2. 框架提供基础功能 框架更多的是从开发规范的角度说,比如说MVC规范的框架。 ### 架构 > 软件架构指软件系统的“基础结构”,创造这些基础结构的准则,以及对这些结构的描述。 1. 架构关注结构 根据老师的重新定义: 1. 架构需要明确系统包含哪些个体 2. 架构需要明确个体运作和协作的规则 3. 架构关注的是顶层架构,不要和子系统的架构混淆 ## 总结 阐明并区分了几个容易混淆、模糊的概念。 _先这样吧_ 若有错误之处请指出,更多地关注[煎鱼](http://www.jianyujianyu.com)。 https://www.mkshell.com/posts/1995-07-31-arichtect-learning-extensibility/ https://www.mkshell.com/posts/1995-07-31-arichtect-learning-extensibility.md 1995-07-31 # 学架构笔记6:可扩展性复杂 可扩展性指系统为了应对将来需求变化而提供的一种扩展能力。就是以后有新需求来的时候,改起来就会简单点。 自从面向对象思想和设计模式出现后,程序的可扩展性都得到了很大程度的解决。但是,可扩展性是否存在还得看技术人员写程序时候的想法。 ## 预测变化 世界上唯一不变的就是变化了,现代互联网软件的迭代更是将这个理念发挥到极致。三天一个小需求,五天一个大需求是常见的事情。 如果能在设计架构的时候,就能预测到未来的需求,以后写需求的时候能少写些代码,对于技术人员来说就是最好不过的事了。 当然理想是美好的,现实却是复杂的。 如果预测错了,那么本架构设计对于后面的变化没有益处,甚至还可能有坏处。多做的工作也就白费了。 1. 不能每个设计点都考虑可扩展性 2. 不能完全不考虑可扩展性 3. 所有的预测都存在出错的可能性 ## 应对变化 就算在设计阶段已经预测到了变化,但是,如果实现变化又是另一回事了。 ### 变化层和稳定层 系统需要拆分出变化层和稳定层,即有哪些是必然需要的,而哪些是未来可能需要的,也不用考虑未来不会需要的。 #### 抽象 对于变化层来说,需要对代码中多个接口实现进行抽象,而这点就将可扩展性的复杂度体现了出来。 而在抽象后,由于抽象不能轻易修改了,那么后续加入的新的实现是否还能沿用这个抽象。 设计模式在这里被运用地淋漓尽致。这也是为什么新手总觉得设计模式啰嗦而老手总觉得设计模式很有用。 ## 总结 学习了软件中可扩展性带来的复杂度。 _先这样吧_ 若有错误之处请指出,更多地关注[煎鱼](http://www.jianyujianyu.com)。 https://www.mkshell.com/posts/1995-07-31-arichtect-learning-high-availability/ https://www.mkshell.com/posts/1995-07-31-arichtect-learning-high-availability.md 1995-07-31 # 学架构笔记5:高可用复杂 维基百科: > 系统无中断地执行其功能的能力,代表系统的可用性程度,是进行系统设计时的准则之一。 关键点:无中断 软件和硬件本质上都无法做到无中断。现在的方法本质上都是通过冗余实现高可用。 尽管都是加机器,高性能复杂和高可用复杂依然有区别:高性能增加机器目的在于**扩展**处理性能,高可用增加机器目的在于**冗余**处理单元。 为了高可用引入了冗余,同时也给程序带来了复杂。 ## 计算高可用 计算指业务的逻辑处理,特点是在不同的机器上输入相同的数据、相同的逻辑处理,产出结果肯定是一样的。 计算高可用的复杂在于: 1. 引入任务分配器,需要维护 2. 任务分配器和业务服务器间连接的管理 3. 分配算法(不存在某一算法一定优于另外的算法) ## 存储高可用 存储与计算相比区别是:将数据从一台机器搬到另一台机器,需要经过线路进行传输,而传输时间不稳定。 延迟意味着整个系统在某个时间点上数据可能不一致,这个时候就算是相同的业务逻辑,也无法得出相同的产出结果。 除了延迟了,网络传输线路也存在可用性问题,如错包、丢包甚至断线。如果线路断线,意味着一段时间内存储数据无法同步。 高存储可用的难点不在于备份数据,而在于如何减少或规避数据不一致对业务造成的影响。 ## 高可用状态决策 计算高可用和存储高可用的基础都是状态决策,即系统能够判断当前的状态是正常还是异常的,为了保证高可用需要立刻处理异常状态。 而决策却总是有多种的,可能对一个异常多种的决策都适用,也有可能只有一种较优决策。 ### 独裁式 存在一个独立的决策主体,负责收集所有的信息,然后进行整体操作的把控。 独裁者的问题在于决策者只有一个,在决策者出故障后整个系统会进入严重的瘫痪。 ### 协商式 两个独立的个体沟通最后进行决策,如最常见的主备决策。 举个例子,主备决策: 1. 启动时两台都是备机 2. 两台服务器建立连接 3. 两台服务器交换状态信息 4. 其中一台做出决策,成为主机,另一台成为备机。 当主备连接中断时,无论是备机升级为主机还是备机不升级(无法得知主机是否真的宕机),都是不适当的。因此可以增加更多的链接降低网络带来的影响。 ### 民主式 多个独立的个体通过投票的方式进行状态决策,如Zookeeper选主。 民主式与协商式很类似,但是是按照多数取胜的方式来的。 民主式存在的问题是脑裂,即一个集群里面出现了两个主机,解决方案可以采用“投票节点数必须超过系统总结点数的一半”。 ## 总结 学习了软件中高可用带来的复杂度以及几种决策方式。 _先这样吧_ 若有错误之处请指出,更多地关注[煎鱼](http://www.jianyujianyu.com)。 https://www.mkshell.com/posts/1995-07-31-arichtect-learning-high-performance/ https://www.mkshell.com/posts/1995-07-31-arichtect-learning-high-performance.md 1995-07-31 # 学架构笔记4:高性能复杂 ## 单机复杂度 计算机内部最关键的地方是操作系统,操作系统和性能最相关的就是进程和线程。 批处理程序的计算机一次只能执行一个任务,如果某任务需要从IO设备读取数据,CPU在此时则是空闲的。因此为了提升CPU利用率,引入了进程。为了达到多进程并行运行的目的,采取了分时的方式。 多进程每个进程数据不共享,如果两个进程需要通信,则需要依托中间存储介质,效率低且程序复杂。引入进程间通信,包括管道、消息队列、信号量、共享存储。 多进程的每个进程内部只能串行,也需要并行处理。引入线程,同时为了保证数据的正确性,引入互斥锁机制。从此,线程是操作系统调度的最小单位,进程是操作系统分配资源的最小单位。 多线程、多进程本质上还是分时系统,引入多个CPU实现真正意义上的多任务并行,有解决方案:对称多处理器结构SMP、非一致存储访问结构NUMA、海量并行处理结构MPP,其中SMP最常见。 ## 集群复杂度 计算机硬件的发展速度远远比不上业务的发展速度。单机性能无法支撑业务,必须采用机器集群。 ### 任务分配 不同的任务分配到不同的机器上执行,拆分业务的复杂度体现在: 1. 需要增加任务分配器,可能是硬件网络设备(F5、交换机),可能是软件网络设备(LVS),可能是复杂均衡软件(Nginx、HAProxy),如果自研系统则还需要更多的维护成本。 2. 任务分配器和业务服务器之间有连接交互 3. 任务分配器需要新增分配算法。 4. 任务分配器在大量请求下也需要多台,结构会更复杂 ### 任务分解 通过以上任务分配可以突破单台机器性能的瓶颈,但是随着业务增长,单纯的任务分配和增加机器已经不能获得相应的收益,引入“任务分解”。 将一个业务的任务或者功能从逻辑的角度分解成不同的小功能,独立出来一个个业务子系统。 虽然代码量可能不减反增,但能提高性能的原因如下: 1. 子系统简单而容易优化 2. 易扩展 但是分解的粒度必须要把握好,而度量的关键在于网络的延迟不占主要延时 —— 如果粒度过细,则需要消耗太多的时间作连接和传输的工作。 ## 总结 学习了软件中高性能带来的复杂度,一是体现在单机内部的复杂度,例如多线程多进程,另一个体现在集群的复杂度。 _先这样吧_ 若有错误之处请指出,更多地关注[煎鱼](http://www.jianyujianyu.com)。 https://www.mkshell.com/posts/1995-07-31-arichtect-learning-history-purpose/ https://www.mkshell.com/posts/1995-07-31-arichtect-learning-history-purpose.md 1995-07-31 # 学架构笔记2和3:架构的历史和目的 ## 历史 ### 软件历史 1. 机器语言:直接使用二进制码0和1进行编程 2. 汇编语言:清晰了,仍需要了解底层 3. 高级语言:程序员不需要再关注及其底层 4. 第一次软件危机:软件逻辑复杂。提出软件工程、结构化程序设计(自顶向下、模块化),控制复杂度 5. 第二次软件危机:软件扩展跟不上硬件。面向对象流行 ### 软件架构历史 软件架构出现:随着软件系统规模的增加,计算相关的算法和数据结构不再构成主要的设计问题;当系统由许多部分组成时,整个系统的组织,也就是所说的软件架构,导致了一系列新的设计问题。 只有较大规模的软件系统才会面临软件架构相关的问题: - 系统规模庞大,耦合严重,开发效率低 - 耦合严重,扩展、修改难 - 逻辑复杂、排错困难 ## 目的 架构并非必要,也不一定提升效率,也不一定能促进业务发展。不能因为需要架构而硬上架构。 **架构设计的真正目的是降低软件的复杂度。** ### 举例分析 #### 需求 大学学生管理系统,包括登录、注册、成绩管理、课程管理等。 #### 分析 - 性能:访问频率不高,性能不怎么复杂。MySQL胜任。 - 可扩展性:该类系统功能基本不便,可扩展性不复杂。 - 高可用:宕机影响不大,负载均衡不需要;数据需要高可用,因此使用主备方案。 - 安全性:不是强隐私,中等强度。 - 成本:几台服务器足够。 根据以上分析,可得架构: ![](https://source.mkshell.com/15278772066021.jpg) ## 总结 回顾软件和架构的发展历史,重申架构的真正目的,举例分析架构设计的目的和方法。 _先这样吧_ 若有错误之处请指出,更多地关注[煎鱼](http://www.jianyujianyu.com)。 https://www.mkshell.com/posts/1995-07-31-arichtect-learning-low-cost-secure-scale/ https://www.mkshell.com/posts/1995-07-31-arichtect-learning-low-cost-secure-scale.md 1995-07-31 # 学架构笔记7:低成本、安全、规模复杂 ## 低成本 成本会在机器量大时成为十分重要的考虑点。 在高性能、高可用的架构中,我们需要增加机器以完成需求,而在低成本的需求中,我们要削减机器。两者的冲突大大地提高了架构设计的难点。 往往只有“创新”才能达到低成本,包括开创一个新的技术领域,或引入新技术。 如: - NoSQL的引入解决了关系型数据库无法应对高并发访问带来的压力 - 全文搜索引擎的引入解决了关系型数据库like低效的问题 - Hadoop的出现解决了传统文件系统无法应对海量数据存储和计算的问题 - HHVM的出现解决了PHP低效的问题 - 新浪将Redis/MC + Mysql改成Redis/MC + SSD Cache + Mysql降低了访问压力 - Kafka的出现带来了高效的消息系统 这些都是复杂的事情,在于需要学习新技术,并将新技术和已有技术融合,同时保证对比旧技术有质的飞跃。 ## 安全 安全对业务和企业形象的影响非常大。 ### 功能安全 XSS、CSRF、SQL注入、系统漏洞等都是利用漏洞。框架内嵌了很多常见的安全功能,但也局限于可预见的,如果出现未知的也框架也无可奈何。 功能安全是一个逐步完善的过程,因为我们无法预知下一个危险点在哪里。 ### 架构安全 传统的架构安全依靠防火墙,也就是隔离。流量大了,墙该破的还是破,不该破的还得花很多钱维护。 目前也没有较好的方法。 ## 规模 一个普通的系统最常见的复杂性,就是功能越加越多,量变引起质变。 1. 功能越来越多,导致系统复杂度指数级上升。因为系统的复杂度是系统数加上系统之间的联系数。 2. 数据越来越多,复杂度也会质变,不管是存储还是计算 ## 总结 1. 低成本复杂度主要是需要引入新技术 2. 安全复杂度主要是未知漏洞和恶意攻击 3. 规模方面复杂度主要是“量变引起质变” _先这样吧_ 若有错误之处请指出,更多地关注[煎鱼](http://www.jianyujianyu.com)。