阅读视图

发现新文章,点击刷新页面。

每日一题-颠倒二进制位🟢

颠倒给定的 32 位有符号整数的二进制位。

 

示例 1:

输入:n = 43261596

输出:964176192

解释:

整数 二进制
43261596 00000010100101000001111010011100
964176192 00111001011110000010100101000000

示例 2:

输入:n = 2147483644

输出:1073741822

解释:

整数 二进制
2147483644 01111111111111111111111111111100
1073741822 00111111111111111111111111111110

 

提示:

  • 0 <= n <= 231 - 2
  • n 为偶数

 

进阶: 如果多次调用这个函数,你将如何优化你的算法?

O(1) 位运算分治,原理讲解(Python/Java/C++/Go)

以反转一个 $8$ 位整数为例。

为方便阅读,我把这个数字记作 $12345678$。目标是得到 $87654321$。

用分治思考,反转 $12345678$ 可以分成如下三步:

  1. 递归反转左半 $1234$,得到 $4321$。
  2. 递归反转右半 $5678$,得到 $8765$。
  3. 交换 $4321$ 和 $8765$,得到 $87654321$。

反转 $1234$ 可以拆分为反转 $12$ 和 $34$,反转 $5678$ 可以拆分为反转 $56$ 和 $78$。

对于 $12$ 这种长为 $2$ 的情况,交换 $1$ 和 $2$ 即可完成反转。

无法加载 SVG 图片,请在网页上查看

你可能会问:这样做,算法能更快吗?

利用位运算「并行计算」的特点,我们可以高效地实现上述过程。

去掉递归的「递」,直接看「归」的过程(自底向上)。

递归的最底层是反转 $12$,反转 $34$,反转 $56$,反转 $78$。利用位运算,这些反转可以同时完成

$$
\begin{array}{c}
\text{12345678} \
\left\downarrow \rule{0pt}{1.5em} \right. \rlap{\text{分离}} \
\text{1\phantom{2}3\phantom{4}5\phantom{6}7\phantom{8}} \
\text{\phantom{1}2\phantom{3}4\phantom{5}6\phantom{7}8} \
\left\downarrow \rule{0pt}{1.5em} \right. \rlap{\text{移位}} \
\text{\phantom{2}1\phantom{2}3\phantom{4}5\phantom{6}7} \
\text{2\phantom{3}4\phantom{5}6\phantom{7}8\phantom{7}} \
\left\downarrow \rule{0pt}{1.5em} \right. \rlap{\text{合并}} \
\text{21436587} \
\end{array}
$$

然后两个两个交换:

$$
\begin{array}{c}
\text{21436587} \
\left\downarrow \rule{0pt}{1.5em} \right. \rlap{\text{分离}} \
\text{21\phantom{11}65\phantom{11}} \
\text{\phantom{11}43\phantom{11}87} \
\left\downarrow \rule{0pt}{1.5em} \right. \rlap{\text{移位}} \
\text{\phantom{11}21\phantom{11}65} \
\text{43\phantom{11}87\phantom{11}} \
\left\downarrow \rule{0pt}{1.5em} \right. \rlap{\text{合并}} \
\text{43218765} \
\end{array}
$$

然后四个四个交换:

$$
\begin{array}{c}
\text{43218765} \
\left\downarrow \rule{0pt}{1.5em} \right. \rlap{\text{分离}} \
\text{4321\phantom{1111}} \
\text{\phantom{1111}8765} \
\left\downarrow \rule{0pt}{1.5em} \right. \rlap{\text{移位}} \
\text{\phantom{1111}4321} \
\text{8765\phantom{1111}} \
\left\downarrow \rule{0pt}{1.5em} \right. \rlap{\text{合并}} \
\text{87654321} \
\end{array}
$$

依此类推。

对于 $32$ 位整数,还需要执行八个八个交换,最后把高低 $16$ 位交换。

m0 = 0x55555555  # 01010101 ...
m1 = 0x33333333  # 00110011 ...
m2 = 0x0f0f0f0f  # 00001111 ...
m3 = 0x00ff00ff  # 00000000111111110000000011111111
m4 = 0x0000ffff  # 00000000000000001111111111111111

class Solution:
    def reverseBits(self, n: int) -> int:
        n = n>>1&m0 | (n&m0)<<1  # 交换相邻位
        n = n>>2&m1 | (n&m1)<<2  # 两个两个交换
        n = n>>4&m2 | (n&m2)<<4  # 四个四个交换
        n = n>>8&m3 | (n&m3)<<8  # 八个八个交换
        return n>>16 | (n&m4)<<16  # 交换高低 16 位
class Solution {
    private static final int m0 = 0x55555555; // 01010101 ...
    private static final int m1 = 0x33333333; // 00110011 ...
    private static final int m2 = 0x0f0f0f0f; // 00001111 ...
    private static final int m3 = 0x00ff00ff; // 00000000111111110000000011111111

    public int reverseBits(int n) {
        n = n>>>1&m0 | (n&m0)<<1; // 交换相邻位
        n = n>>>2&m1 | (n&m1)<<2; // 两个两个交换
        n = n>>>4&m2 | (n&m2)<<4; // 四个四个交换
        n = n>>>8&m3 | (n&m3)<<8; // 八个八个交换
        return n>>>16 | n<<16;    // 交换高低 16 位
    }
}
class Solution {
    static constexpr uint32_t m0 = 0x55555555; // 01010101 ...
    static constexpr uint32_t m1 = 0x33333333; // 00110011 ...
    static constexpr uint32_t m2 = 0x0f0f0f0f; // 00001111 ...
    static constexpr uint32_t m3 = 0x00ff00ff; // 00000000111111110000000011111111

    uint32_t reverseBits32(uint32_t n) {
        n = n>>1&m0 | (n&m0)<<1; // 交换相邻位
        n = n>>2&m1 | (n&m1)<<2; // 两个两个交换
        n = n>>4&m2 | (n&m2)<<4; // 四个四个交换
        n = n>>8&m3 | (n&m3)<<8; // 八个八个交换
        return n>>16 | n<<16;    // 交换高低 16 位
    }

public:
    int reverseBits(int n) {
        return reverseBits32(n);
    }
};
const m0 = 0x55555555 // 01010101 ...
const m1 = 0x33333333 // 00110011 ...
const m2 = 0x0f0f0f0f // 00001111 ...
const m3 = 0x00ff00ff // 00000000111111110000000011111111
const m4 = 0x0000ffff // 00000000000000001111111111111111

func reverseBits(n int) int {
n = n>>1&m0 | n&m0<<1   // 交换相邻位
n = n>>2&m1 | n&m1<<2   // 两个两个交换
n = n>>4&m2 | n&m2<<4   // 四个四个交换
n = n>>8&m3 | n&m3<<8   // 八个八个交换
return n>>16 | n&m4<<16 // 交换高低 16 位
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(1)$。无论输入的是 $0$ 还是 $2^{31}-2$,计算量没有任何区别。更精细地说,时间复杂度是 $\mathcal{O}(\log W)$,其中 $W=32$ 是位宽。
  • 空间复杂度:$\mathcal{O}(1)$。

附:库函数写法

class Solution:
    def reverseBits(self, n: int) -> int:
        # 没有 O(1) 的库函数,只能用字符串转换代替
        return int(bin(n)[2:].zfill(32)[::-1], 2)
class Solution {
    public int reverseBits(int n) {
        return Integer.reverse(n);
    }
}
class Solution {
public:
    int reverseBits(int n) {
        return __builtin_bitreverse32(n);
    }
};
func reverseBits(n int) int {
return int(bits.Reverse32(uint32(n)))
}

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

【负雪明烛】「循环」与「分治」解法

各位题友大家好! 今天是 @负雪明烛 坚持日更的第 64 天。今天力扣上的每日一题是「190. 颠倒二进制位」。

解题思路

今天的题目是要求将一个数字,把其二进制翻转,求得到的另外一个二进制数。

方法一:循环

这是最容易想到的方法了,每次把 res 左移,把 $n$ 的二进制末尾数字,拼接到结果 res 的末尾。然后把 $n$ 右移。

举一个 8 位的二进制进行说明:

i n res
- 11001001 -
0 1100100 1
1 110010 10
2 11001 100
3 1100 1001
4 110 10010
5 11 100100
6 1 1001001
8 - 10010011

代码如下:

###Python

class Solution:
    # @param n, an integer
    # @return an integer
    def reverseBits(self, n):
        res = 0
        for i in range(32):
            res = (res << 1) | (n & 1)
            n >>= 1
        return res

###C++

class Solution {
public:
    uint32_t reverseBits(uint32_t n) {
        uint32_t res = 0;
        for (int i = 0; i < 32; ++i) {
            res = (res << 1) | (n & 1);
            n >>= 1;
        }
        return res;
    }
};
  • 时间复杂度:$O(1)$
  • 空间复杂度:$O(1)$

方法二:分而治之

有另外一种不使用循环的做法,类似于归并排序

其思想是分而治之,把数字分为两半,然后交换这两半的顺序;然后把前后两个半段都再分成两半,交换内部顺序……直至最后交换顺序的时候,交换的数字只有 1 位。

以一个 8 位的二进制数字为例:

190.001.jpeg

代码如下:

###Python

class Solution:
    # @param n, an integer
    # @return an integer
    def reverseBits(self, n):
        n = (n >> 16) | (n << 16);
        n = ((n & 0xff00ff00) >> 8) | ((n & 0x00ff00ff) << 8);
        n = ((n & 0xf0f0f0f0) >> 4) | ((n & 0x0f0f0f0f) << 4);
        n = ((n & 0xcccccccc) >> 2) | ((n & 0x33333333) << 2);
        n = ((n & 0xaaaaaaaa) >> 1) | ((n & 0x55555555) << 1);
        return n;

###C++

class Solution {
public:
    uint32_t reverseBits(uint32_t n) {
        n = (n >> 16) | (n << 16);
        n = ((n & 0xff00ff00) >> 8) | ((n & 0x00ff00ff) << 8);
        n = ((n & 0xf0f0f0f0) >> 4) | ((n & 0x0f0f0f0f) << 4);
        n = ((n & 0xcccccccc) >> 2) | ((n & 0x33333333) << 2);
        n = ((n & 0xaaaaaaaa) >> 1) | ((n & 0x55555555) << 1);
        return n;
    }
};
  • 时间复杂度:$O(1)$
  • 空间复杂度:$O(1)$

刷题心得

位运算还是很有意思的。


参考资料:


OK,以上就是 @负雪明烛 写的今天题解的全部内容了,如果你觉得有帮助的话,求赞、求关注、求收藏。如果有疑问的话,请在下面评论,我会及时解答。

关注我,你将不会错过我的精彩动画题解、面试题分享、组队刷题活动,进入主页 @负雪明烛 右侧有刷题组织,从此刷题不再孤单。

祝大家牛年大吉!AC 多多,Offer 多多!我们明天再见!

颠倒二进制位

方法一:逐位颠倒

思路

将 $n$ 视作一个长为 $32$ 的二进制串,从低位往高位枚举 $n$ 的每一位,将其倒序添加到翻转结果 $\textit{rev}$ 中。

代码实现中,每枚举一位就将 $n$ 右移一位,这样当前 $n$ 的最低位就是我们要枚举的比特位。当 $n$ 为 $0$ 时即可结束循环。

需要注意的是,在某些语言(如 $\texttt{Java}$)中,没有无符号整数类型,因此对 $n$ 的右移操作应使用逻辑右移。

代码

###C++

class Solution {
public:
    uint32_t reverseBits(uint32_t n) {
        uint32_t rev = 0;
        for (int i = 0; i < 32 && n > 0; ++i) {
            rev |= (n & 1) << (31 - i);
            n >>= 1;
        }
        return rev;
    }
};

###Java

public class Solution {
    public int reverseBits(int n) {
        int rev = 0;
        for (int i = 0; i < 32 && n != 0; ++i) {
            rev |= (n & 1) << (31 - i);
            n >>>= 1;
        }
        return rev;
    }
}

###Go

func reverseBits(n uint32) (rev uint32) {
    for i := 0; i < 32 && n > 0; i++ {
        rev |= n & 1 << (31 - i)
        n >>= 1
    }
    return
}

###JavaScript

var reverseBits = function(n) {
    let rev = 0;
    for (let i = 0; i < 32 && n > 0; ++i) {
        rev |= (n & 1) << (31 - i);
        n >>>= 1;
    }
    return rev >>> 0;
};

###C

uint32_t reverseBits(uint32_t n) {
    uint32_t rev = 0;
    for (int i = 0; i < 32 && n > 0; ++i) {
        rev |= (n & 1) << (31 - i);
        n >>= 1;
    }
    return rev;
}

###Python

class Solution:
    def reverseBits(self, n: int) -> int:
        rev = 0
        for i in range(32):
            if n == 0:
                break
            rev |= (n & 1) << (31 - i)
            n >>= 1
        return rev

###C#

public class Solution {
    public int ReverseBits(int n) {
        int rev = 0;
        for (int i = 0; i < 32 && n != 0; ++i) {
            rev |= (n & 1) << (31 - i);
            n = (int)((uint)n >> 1);
        }
        return rev;
    }
}

###TypeScript

function reverseBits(n: number): number {
    let rev = 0;
    for (let i = 0; i < 32 && n !== 0; ++i) {
        rev |= (n & 1) << (31 - i);
        n >>>= 1; 
    }
    return rev >>> 0; 
}

###Rust

impl Solution {
    pub fn reverse_bits(n: i32) -> i32 {
        let mut x = n;
        let mut rev = 0;
        
        for i in 0..32 {
            if x == 0 {
                break;
            }
            rev |= (x & 1) << (31 - i);
            x >>= 1;
        }
        
        rev
    }
}

复杂度分析

  • 时间复杂度:$O(\log n)$。

  • 空间复杂度:$O(1)$。

方法二:位运算分治

思路

若要翻转一个二进制串,可以将其均分成左右两部分,对每部分递归执行翻转操作,然后将左半部分拼在右半部分的后面,即完成了翻转。

由于左右两部分的计算方式是相似的,利用位掩码和位移运算,我们可以自底向上地完成这一分治流程。

fig1{:width="60%"}

对于递归的最底层,我们需要交换所有奇偶位:

  1. 取出所有奇数位和偶数位;
  2. 将奇数位移到偶数位上,偶数位移到奇数位上。

类似地,对于倒数第二层,每两位分一组,按组号取出所有奇数组和偶数组,然后将奇数组移到偶数组上,偶数组移到奇数组上。以此类推。

需要注意的是,在某些语言(如 $\texttt{Java}$)中,没有无符号整数类型,因此对 $n$ 的右移操作应使用逻辑右移。

代码

###C++

class Solution {
private:
    const uint32_t M1 = 0x55555555; // 01010101010101010101010101010101
    const uint32_t M2 = 0x33333333; // 00110011001100110011001100110011
    const uint32_t M4 = 0x0f0f0f0f; // 00001111000011110000111100001111
    const uint32_t M8 = 0x00ff00ff; // 00000000111111110000000011111111

public:
    uint32_t reverseBits(uint32_t n) {
        n = n >> 1 & M1 | (n & M1) << 1;
        n = n >> 2 & M2 | (n & M2) << 2;
        n = n >> 4 & M4 | (n & M4) << 4;
        n = n >> 8 & M8 | (n & M8) << 8;
        return n >> 16 | n << 16;
    }
};

###Java

public class Solution {
    private static final int M1 = 0x55555555; // 01010101010101010101010101010101
    private static final int M2 = 0x33333333; // 00110011001100110011001100110011
    private static final int M4 = 0x0f0f0f0f; // 00001111000011110000111100001111
    private static final int M8 = 0x00ff00ff; // 00000000111111110000000011111111

    public int reverseBits(int n) {
        n = n >>> 1 & M1 | (n & M1) << 1;
        n = n >>> 2 & M2 | (n & M2) << 2;
        n = n >>> 4 & M4 | (n & M4) << 4;
        n = n >>> 8 & M8 | (n & M8) << 8;
        return n >>> 16 | n << 16;
    }
}

###Go

const (
    m1 = 0x55555555 // 01010101010101010101010101010101
    m2 = 0x33333333 // 00110011001100110011001100110011
    m4 = 0x0f0f0f0f // 00001111000011110000111100001111
    m8 = 0x00ff00ff // 00000000111111110000000011111111
)

func reverseBits(n uint32) uint32 {
    n = n>>1&m1 | n&m1<<1
    n = n>>2&m2 | n&m2<<2
    n = n>>4&m4 | n&m4<<4
    n = n>>8&m8 | n&m8<<8
    return n>>16 | n<<16
}

###JavaScript

var reverseBits = function(n) {
    const M1 = 0x55555555; // 01010101010101010101010101010101
    const M2 = 0x33333333; // 00110011001100110011001100110011
    const M4 = 0x0f0f0f0f; // 00001111000011110000111100001111
    const M8 = 0x00ff00ff; // 00000000111111110000000011111111

    n = n >>> 1 & M1 | (n & M1) << 1;
    n = n >>> 2 & M2 | (n & M2) << 2;
    n = n >>> 4 & M4 | (n & M4) << 4;
    n = n >>> 8 & M8 | (n & M8) << 8;
    return (n >>> 16 | n << 16) >>> 0;
};

###C

const uint32_t M1 = 0x55555555;  // 01010101010101010101010101010101
const uint32_t M2 = 0x33333333;  // 00110011001100110011001100110011
const uint32_t M4 = 0x0f0f0f0f;  // 00001111000011110000111100001111
const uint32_t M8 = 0x00ff00ff;  // 00000000111111110000000011111111

uint32_t reverseBits(uint32_t n) {
    n = n >> 1 & M1 | (n & M1) << 1;
    n = n >> 2 & M2 | (n & M2) << 2;
    n = n >> 4 & M4 | (n & M4) << 4;
    n = n >> 8 & M8 | (n & M8) << 8;
    return n >> 16 | n << 16;
}

###Python

class Solution:
    def reverseBits(self, n: int) -> int:
        M1 = 0x55555555  # 01010101010101010101010101010101
        M2 = 0x33333333  # 00110011001100110011001100110011
        M4 = 0x0f0f0f0f  # 00001111000011110000111100001111
        M8 = 0x00ff00ff  # 00000000111111110000000011111111
        
        n = n & 0xFFFFFFFF
        n = (n >> 1 & M1) | ((n & M1) << 1)
        n = (n >> 2 & M2) | ((n & M2) << 2)
        n = (n >> 4 & M4) | ((n & M4) << 4)
        n = (n >> 8 & M8) | ((n & M8) << 8)
        return ((n >> 16) | (n << 16)) & 0xFFFFFFFF

###C#

public class Solution {
    private const int M1 = 0x55555555; // 01010101010101010101010101010101
    private const int M2 = 0x33333333; // 00110011001100110011001100110011
    private const int M4 = 0x0f0f0f0f; // 00001111000011110000111100001111
    private const int M8 = 0x00ff00ff; // 00000000111111110000000011111111

    public int ReverseBits(int n) {
        int result = n;
        result = ((int)((uint)result >> 1) & M1) | ((result & M1) << 1);
        result = ((int)((uint)result >> 2) & M2) | ((result & M2) << 2);
        result = ((int)((uint)result >> 4) & M4) | ((result & M4) << 4);
        result = ((int)((uint)result >> 8) & M8) | ((result & M8) << 8);
        return (int)((uint)result >> 16) | (result << 16);
    }
}

###TypeScript

function reverseBits(n: number): number {
    const M1 = 0x55555555; // 01010101010101010101010101010101
    const M2 = 0x33333333; // 00110011001100110011001100110011
    const M4 = 0x0f0f0f0f; // 00001111000011110000111100001111
    const M8 = 0x00ff00ff; // 00000000111111110000000011111111
    
    let result: number = n;
    
    result = ((result >>> 1) & M1) | ((result & M1) << 1);
    result = ((result >>> 2) & M2) | ((result & M2) << 2);
    result = ((result >>> 4) & M4) | ((result & M4) << 4);
    result = ((result >>> 8) & M8) | ((result & M8) << 8);
    
    return ((result >>> 16) | (result << 16)) >>> 0;
}

###Rust

impl Solution {
    pub fn reverse_bits(x: i32) -> i32 {
        const M1: u32 = 0x55555555; // 01010101010101010101010101010101
        const M2: u32 = 0x33333333; // 00110011001100110011001100110011
        const M4: u32 = 0x0f0f0f0f; // 00001111000011110000111100001111
        const M8: u32 = 0x00ff00ff; // 00000000111111110000000011111111

        let mut n = x as u32;
        
        n = (n >> 1 & M1) | (n & M1) << 1;
        n = (n >> 2 & M2) | (n & M2) << 2;
        n = (n >> 4 & M4) | (n & M4) << 4;
        n = (n >> 8 & M8) | (n & M8) << 8;
        (n >> 16 | n << 16) as i32
    }
}

复杂度分析

  • 时间复杂度:$O(1)$。

  • 空间复杂度:$O(1)$。

Memo Code 安全设计:子进程、命令防护与权限审批的统一方案

同步至个人站点:Memo Code 安全设计:子进程、命令防护与权限审批的统一方案

202622

Memo Code 是我最近两个多月投入较多精力的 Agent 项目。类似于Claude Code 和 Codex 的 轻量级本地编程 Agent,目前已具备 Coding Agent 完备技能。

如果你感兴趣的话,欢迎参与:Memo Code - Github,或者给个 Star 鼓励一下哈哈~

做 Agent 这类能「替用户干活」的工具,安全性是躲不掉的坎。

我一开始做 memo(github.com/minorcell/m…)的时候,安全问题还没想那么多——能跑起来就行。后来工具越加越多,shell 命令也越跑越复杂,就开始踩坑了:

  • 子进程忘了关,内存慢慢涨
  • rm -rf / 差点真被我跑出来
  • 每次执行都要点批准,用户体验稀碎

这些问题逼着我认真设计了整套安全方案。今天把思路和实现细节都分享出来,希望对你有帮助。

先想清楚:安全设计要解决什么问题?

我把它拆成三件事:

  1. 资源可控:子进程不能无限开,不能忘了关
  2. 操作安全:危险命令要拦截,误操作要有缓冲
  3. 权限平衡:该拦的拦住,该放的放行,还要给用户留个「后门」

下面逐一展开。

第一道防线:子进程管理——防止内存泄漏与资源耗尽

memo 的 shell 执行用的是 Node.js 的 child_process.spawn,但光 spawn 是不够的——你还得管得住。

统一会话管理器

我写了一个 UnifiedExecManagerpackages/tools/src/tools/exec_runtime.ts),核心思路是单例 + 会话池

class UnifiedExecManager {
  private sessions = new Map<number, SessionState>()
  private nextId = 1
  private MAX_SESSIONS = 64
}

好处很明显:

  • 所有子进程都有唯一 ID
  • 随时可以查询状态、发送信号、获取输出
  • 资源回收有统一入口

资源限制:数量 + 内存 + 时间

先看数量限制:

async start(request: StartExecRequest) {
    this.cleanupSessions()
    if (this.activeSessionCount() >= MAX_SESSIONS) {
        throw new Error(`too many active sessions (max ${MAX_SESSIONS})`)
    }
    // ...
}

超过 64 个活跃会话就直接拒绝,防止被LLM恶意耗尽系统资源。

再看输出限制。Agent 交互是基于 token 计费的,子进程输出不能无限制返回:

function truncateByTokens(text: string, maxOutputTokens?: number) {
  const maxChars = (maxOutputTokens || 2000) * 4
  if (text.length <= maxChars) {
    return { output: text, deliveredChars: text.length }
  }
  return {
    output: text.slice(0, maxChars),
    deliveredChars: maxChars,
  }
}

默认最多返回 8000 字符,不够可以调,但不会无限大。

超时终止:SIGTERM → SIGKILL

子进程跑飞了是常见问题。memo 的策略是先礼貌后强硬

private async terminateForTimeout(session: SessionState) {
    if (session.exited) return
    session.proc.kill('SIGTERM')
    await waitForExit(session, 200)  // 等 200ms
    if (!session.exited) {
        session.proc.kill('SIGKILL')  // 还是没退就直接杀了
        await waitForExit(session, 200)
    }
}

为什么要等一下?因为有些程序接收到 SIGTERM 会做清理工作(比如写入缓存、关闭句柄),直接 SIGKILL 可能导致数据丢失。

内存泄漏防护:自动清理已退出的会话

会话不能只增不减。我加了一个自动清理逻辑:

private cleanupSessions() {
    if (this.sessions.size <= MAX_SESSIONS) return
    // 优先清理已退出的,按启动时间从早到晚排序
    const ended = Array.from(this.sessions.values())
        .filter(session => session.exited)
        .sort((a, b) => a.startedAtMs - b.startedAtMs)

    for (const session of ended) {
        if (this.sessions.size <= MAX_SESSIONS) break
        this.sessions.delete(session.id)
    }
}

这样即使跑了几百个命令,内存也不会无限涨。

第二道防线:命令守卫——拦截危险操作

子进程管住了还不够,还得管住跑什么命令

我见过太多「rm -rf /」惨案,也见过 dd if=/dev/zero of=/dev/sda 这种物理层面不可逆的破坏。memo 的做法是命令解析 + 黑名单匹配

命令解析:不只是字符串匹配

直接正则匹配 rm -rf 是有漏洞的。比如 sudo rm -rf /、包裹在 bash -c 里、甚至写成十六进制,都能绕过简单匹配。

memo 的做法是先把命令拆成「段」,再逐段解析:

function splitCommandSegments(command: string) {
  // 按 ; | && || 分割,处理引号和转义
  // 返回每一段独立的命令
}

function parseSegment(segment: string) {
  // 跳过 sudo/env/nohup 等包装
  // 提取真实的命令名和参数
}

这样不管外面包了多少层 sudo env bash -c,最终都能追溯到真正的命令。

危险命令黑名单

目前 memo 拦截这几类(packages/tools/src/tools/command_guard.ts):

规则 触发条件 危险等级
rm_recursive_critical_target rm -rf 目标包含 /~$HOME 等关键路径 极高
mkfs_filesystem_create mkfs/mkfs.xxx 极高
dd_write_block_device dd 写入 /dev/ 下的块设备 极高
disk_mutation_block_device fdisk/parted/shred 等操作块设备
redirect_block_device 输出重定向到 /dev/ 块设备

拦截后返回的是 <system_hint> 标记,不是直接报错,方便 Agent 理解为什么被拦:

<system_hint type="tool_call_denied"
    tool="exec_command"
    reason="dangerous_command"
    policy="blacklist"
    rule="rm_recursive_critical_target"
    command="rm -rf /">
    Blocked a high-risk shell command to prevent irreversible data loss.
    Use a safer and scoped alternative.
</system_hint>

第三道防线:审批系统——平衡权限与体验

命令守卫是第一道关卡,但还有很多「不危险但需要知道」的操作,比如写文件、改配置。审批系统的目标就是分级管理、可追溯、可配置

风险分级

memo 把工具分成三级(packages/tools/src/approval/constants.ts):

级别 含义 审批策略(auto 模式)
read 只读操作 免审批
write 文件修改 需审批
execute 执行命令 需审批

审批模式

  • auto 模式:只读工具免审批,写/执行类工具需要审批
  • strict 模式:所有工具都需要审批,一个都跑不掉
check(toolName: string, params: unknown): ApprovalCheckResult {
    if (ALWAYS_AUTO_APPROVE_TOOLS.has(toolName)) {
        return { needApproval: false, decision: 'auto-execute' }
    }

    const riskLevel = classifier.getRiskLevel(toolName)
    if (!classifier.needsApproval(riskLevel, approvalMode)) {
        return { needApproval: false, decision: 'auto-execute' }
    }
    // 生成指纹,返回需要审批
}

审批记忆:一次批准,记住一整场

如果每次执行都要点批准,用户体验会非常差。memo 用指纹 + 缓存解决这个问题:

const fingerprint = generateFingerprint(toolName, params)
cache.toolByFingerprint.set(fingerprint, toolName)

// 审批后记录
recordDecision(fingerprint, decision: 'session' | 'once' | 'deny') {
    switch (decision) {
        case 'session': cache.sessionTools.add(toolName); break
        case 'once': cache.onceTools.add(toolName); break
        case 'deny': cache.deniedTools.add(toolName); break
    }
}
  • session:这场对话内一直有效
  • once:用一次就失效
  • deny:以后再问直接拦截

dangerous 模式

审批系统是安全了,但有时候用户就是想要「无限制」——比如在本地开发、或者明确知道自己在干什么。

memo 提供了 dangerous 模式:

if (dangerous) {
  return {
    isDangerousMode: true,
    getRiskLevel: () => 'read', // 所有操作都视为最低风险
    check: () => ({ needApproval: false, decision: 'auto-execute' }),
    isGranted: () => true,
  }
}

开启也很简单,CLI 里加上 --dangerous 标记:

memo --dangerous

开启后:

  • 所有工具都免审批

这是一把双刃剑。 我在 CLI 里加了这个选项,但默认是关闭的。开发者如果想用,需要明确加上 --dangerous 标记。

总结:三层防护 + 一个后门

memo 的安全设计可以总结为:

  1. 子进程管理:数量限制 + 输出截断 + 超时终止 + 自动清理
  2. 命令守卫:命令解析 + 黑名单拦截 + stdin 检测
  3. 审批系统:风险分级 + 审批模式 + 记忆缓存
  4. dangerous 模式:留一个「我知道我在干什么」的后门

这套方案不完美,还在持续迭代。比如命令守卫目前是硬编码的黑名单,后续可以考虑支持用户自定义规则;审批系统也可以考虑接入外部信任模型。

(完)

1.Flutter 环境配置 & Shell 基础知识笔记

Flutter 环境配置 & Shell 基础知识笔记


一、Flutter 环境变量配置(实践总结)

需要配置哪些环境变量?

环境变量 是否必须 作用
PATH ✅ 必须 让终端能找到 flutterdart 命令
PUB_HOSTED_URL 🇨🇳 国内必须 Dart 包的下载镜像(不配会很慢或下载失败)
FLUTTER_STORAGE_BASE_URL 🇨🇳 国内必须 Flutter SDK 更新的下载镜像

为什么要配置镜像?

Flutter 默认从 Google 服务器下载资源,国内无法直接访问。配置中国镜像后,所有下载都走国内服务器,速度快且稳定。

常用的中国镜像:

镜像 地址
Flutter 社区镜像 https://pub.flutter-io.cn / https://storage.flutter-io.cn
清华大学镜像 https://mirrors.tuna.tsinghua.edu.cn/dart-pub / https://mirrors.tuna.tsinghua.edu.cn/flutter

我的具体配置

Flutter SDK 安装路径:/Users/hongliangchang/development/flutter

~/.zshrc 末尾添加的内容:

# Flutter 中国镜像(解决国内无法访问 Google 服务器的问题)
export PUB_HOSTED_URL=https://pub.flutter-io.cn
export FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn

# Flutter PATH(让终端能直接使用 flutter 命令)
export PATH="$HOME/development/flutter/bin:$PATH"

# Dart SDK PATH(让终端能直接使用 dart 命令)
export PATH="$HOME/development/flutter/bin/cache/dart-sdk/bin:$PATH"

配置完成后

# 1. 让配置生效
source ~/.zshrc

# 2. 验证 flutter 是否可用
flutter --version

# 3. 检查环境是否完整(会列出缺少的依赖)
flutter doctor

踩坑记录

  1. 配置写错文件:macOS 用的是 zsh,环境变量要写在 ~/.zshrc,不是 ~/.bash_profile
  2. Windows 换行符问题:如果 Flutter SDK 是从 Windows 拷贝过来的,脚本文件会带 \r 换行符,macOS 无法执行,需要在 macOS 上重新下载解压

二、为什么要配置环境变量?

核心原因:让系统知道去哪里找程序。

当你在终端输入 flutter --version 时,系统不会搜遍整个电脑找 flutter,它只会去 PATH 环境变量列出的目录 里找。

# 查看当前 PATH 里有哪些目录
echo $PATH

不配置会怎样?

# ❌ 不配置 PATH,每次必须写完整路径
/Users/hongliangchang/development/flutter/bin/flutter --version

# ✅ 配置了 PATH,直接输名字
flutter --version

通俗比喻:好比手机通讯录存了一个人的号码(配置 PATH),以后打电话搜名字就行。不存的话,每次都得手动输完整手机号码(完整路径)。

PATH 之外的环境变量

环境变量不只是 PATH,还能存各种配置信息:

环境变量 作用
PATH 告诉系统去哪些目录找程序
PUB_HOSTED_URL 告诉 Flutter 从哪个镜像下载 Dart 包(中国镜像加速)
FLUTTER_STORAGE_BASE_URL 告诉 Flutter 从哪个镜像下载 SDK(中国镜像加速)

三、配置文件的区别

不同 Shell 读取不同的配置文件,这是环境变量不生效的常见原因:

Shell 配置文件
bash ~/.bash_profile~/.bashrc
zsh ~/.zshrc~/.zprofile

⚠️ 如果你的 Mac 用的是 zsh,环境变量写在 ~/.bash_profile 里是不生效的,必须写在 ~/.zshrc 里。

配置完后让其生效:

source ~/.zshrc

四、什么是 Shell?

Shell 就是你打开「终端」后,帮你执行命令的程序。可以理解为一个「翻译官」,把你输入的命令翻译给操作系统执行。

常见的 Shell 有 sh、bash、zsh、fish 等,它们功能类似但各有增强。


五、Bash 和 Zsh 是什么?

名称 全称 含义
sh Bourne Shell 最古老的 Shell,以作者 Stephen Bourne 命名
bash Bourne Again Shell sh 的增强版,"重生的 Bourne Shell"(双关语 born again = 重生)
zsh Z Shell bash 的增强版,名字来自普林斯顿助教邵中(Zhong Shao)的用户名

继承关系

sh(祖宗)
 └── bash(儿子,增强版)
      └── zsh(孙子,更强大)

六、macOS 默认用哪个 Shell?

  • macOS Catalina(10.15)之前:默认 bash
  • macOS Catalina(10.15)及之后:默认 zsh

查看当前 Shell:

echo $SHELL
# /bin/zsh → 用的 zsh
# /bin/bash → 用的 bash

为什么苹果要从 bash 换成 zsh?

bash 新版本改用了 GPLv3 许可证,苹果不愿接受。

GPLv3 的核心要求:如果你在产品中使用了 GPLv3 的软件,用户修改了这个软件后,你必须允许用户把修改版装回设备运行

这和苹果的封闭生态冲突——macOS/iOS 的系统文件都有代码签名,不允许用户随意替换。

通俗比喻:苹果卖你一辆车,车里装了一台 GPLv3 的发动机。GPLv3 说车主可以自己改造发动机并装回去,但苹果不愿意让你动它的车。所以苹果换了一台 MIT 许可的发动机(zsh),没有任何限制。

最终苹果的做法:

  • 系统自带的 bash 停留在 3.2 版本(2007 年的,最后一个 GPLv2 版本)
  • 默认 Shell 改为 zsh(MIT 许可证,没有"传染性"要求)

七、Oh-My-Zsh 是什么?

Oh-My-Zsh = zsh 的「插件和主题管理器」,它不改变 zsh 核心功能,而是让体验更好。

zsh = 引擎(自带 Tab 补全等核心功能)
oh-my-zsh = 改装套件(主题 + 插件)
功能 提供者
Tab 补全命令/路径 zsh 自带
Tab 补全时方向键选择 zsh 自带
终端主题/配色 oh-my-zsh
Git 分支显示在命令行 oh-my-zsh 主题
命令别名(如 gst = git status oh-my-zsh 的 git 插件
根据历史记录灰色提示 oh-my-zsh 的 autosuggestions 插件

八、Zsh 命名趣事

zsh 的作者是 Paul Falstad,1990 年在普林斯顿大学读书时开发。当时有个助教叫邵中(Zhong Shao),他的登录用户名是 zsh,Paul 觉得这名字结尾是 sh,很像一个 Shell 的名字,就直接拿来用了。

邵中本人和 zsh 的开发没有任何关系,他后来成为了耶鲁大学计算机科学系教授,研究编程语言和编译器。

GraphQL 重塑:从 API 语言到 AI 时代的"逻辑神经系统"

"在 AI 重构软件工程的时代,GraphQL 不再只是一种 API 查询语言——它正在成为人机协作的'母语'。"


一、从餐厅点餐说起:为什么你的 API 总在"多给"或"少给"?

想象你走进一家传统餐厅(REST API),服务员递给你一本厚厚的菜单。你只想要一份"番茄炒蛋",但菜单上写的是"套餐 A:番茄炒蛋 + 米饭 + 例汤 + 小菜 + 餐后水果"。你不得不接受整个套餐,即使你只需要那盘炒蛋。这就是 Over-fetching(数据冗余)

更糟糕的是,当你想要"番茄炒蛋 + 宫保鸡丁的酱汁 + 麻婆豆腐的花椒"时,服务员告诉你:"抱歉,我们只提供固定套餐,你需要分别点三份套餐。"于是你被迫跑三趟窗口,拿回三个托盘,再自己拼凑出想要的组合。这就是 Under-fetching(数据不足)

而 GraphQL 呢?它像是一个自助取餐台——你拿着托盘,精确地选择自己想要的每一样食材:

query MyMeal {
  tomatoEgg {
    egg
    tomato
  }
  kungPaoChicken {
    sauce
  }
  mapotofu {
    szechuanPepper
  }
}

一次查询,精确获取,零冗余

REST vs GraphQL:流程对比

让我用一个直观的图表来说明两者的差异:

┌─────────────────────────────────────────────────────────────┐
│                      REST 的多端点困境                        │
└─────────────────────────────────────────────────────────────┘

客户端需求:用户信息 + 最新3篇文章 + 每篇文章的评论数

请求流程:
  ┌─────────┐    GET /api/user/123         ┌─────────┐
  │         │ ─────────────────────────────>│         │
  │         │    返回用户全部字段(冗余)        │         │
  │         │ <─────────────────────────────│         │
  │         │                               │         │
  │  客户端  │    GET /api/posts?user=123   │  服务器  │
  │         │ ─────────────────────────────>│         │
  │         │    返回文章列表(无评论数)        │         │
  │         │ <─────────────────────────────│         │
  │         │                               │         │
  │         │    GET /api/posts/1/comments  │         │
  │         │ ─────────────────────────────>│         │
  │         │ <─────────────────────────────│         │
  │         │    GET /api/posts/2/comments  │         │
  │         │ ─────────────────────────────>│         │
  │         │ <─────────────────────────────│         │
  │         │    GET /api/posts/3/comments  │         │
  │         │ ─────────────────────────────>│         │
  │         │ <─────────────────────────────│         │
  └─────────┘                               └─────────┘
     共 5 次网络往返,大量冗余数据传输


┌─────────────────────────────────────────────────────────────┐
│                   GraphQL 的单一图谱查询                      │
└─────────────────────────────────────────────────────────────┘

  ┌─────────┐    POST /graphql             ┌─────────┐
  │         │ ─────────────────────────────>│         │
  │         │  {                            │         │
  │  客户端  │    user(id: 123) {            │  服务器  │
  │         │      name, avatar             │         │
  │         │      posts(limit: 3) {        │         │
  │         │        title                  │         │
  │         │        commentCount           │         │
  │         │      }                        │         │
  │         │    }                          │         │
  │         │  }                            │         │
  │         │ <─────────────────────────────│         │
  │         │    精确返回所需数据              │         │
  └─────────┘                               └─────────┘
     仅 1 次网络往返,零冗余数据

二、GraphQL 是 AI 时代的"母语":从人类 API 到机器说明书

2.1 确定性契约:消除 AI 的"幻觉"

当你让 ChatGPT 写一段调用某个 REST API 的代码时,它可能会:

  • 猜测字段名(是 user_name 还是 userName?)
  • 臆造端点(/api/v1/users 还是 /users?)
  • 忽略必填参数(导致 400 Bad Request)

这是因为 REST API 的"说明书"通常是人类语言的文档(Swagger/OpenAPI),而 LLM 在解析文档时会产生"理解偏差"。

但 GraphQL 不同。它的核心是一份机器可读的契约——Schema

type User {
  id: ID!              # 感叹号表示必填,AI 无法遗漏
  name: String!
  email: String
  posts: [Post!]!      # 数组类型明确标注
}

type Query {
  user(id: ID!): User  # 参数类型强制约束
}

这份 Schema 像是一张"分子式"——每个字段的类型、是否可空、关系连接都被严格定义。当 AI Agent 读取这份 Schema 时,它不需要"理解文档",只需要解析结构。就像化学家看到 H₂O 就知道如何合成水,AI 看到 Schema 就知道如何构建查询。

示例对比:

REST(文档驱动) GraphQL(Schema 驱动)
"User endpoint returns user object with name and posts" type User { name: String! posts: [Post!]! }
AI 需要"猜测"字段名 AI 直接引用确定的类型定义
版本变更需要重新学习文档 Schema 变更自动反映在类型系统中

2.2 Token 效率:声明式查询降低 AI 的认知负载

在 AI 辅助编程时代,我们需要不断向 LLM 传递上下文(Context)。而 REST API 的命令式特性会导致上下文爆炸

# REST 风格:AI 需要理解 3 个端点的逻辑关系
user = requests.get(f"/api/users/{user_id}")
posts = requests.get(f"/api/posts?user={user_id}")
for post in posts:
    comments = requests.get(f"/api/posts/{post['id']}/comments")
    # ... 处理逻辑

这段代码的"认知成本"包括:

  1. 理解三个端点的 URL 结构
  2. 推断参数传递逻辑(user_idposts
  3. 处理嵌套循环和数据拼接

而 GraphQL 的声明式查询将这一切浓缩为单一意图

query UserWithPosts($userId: ID!) {
  user(id: $userId) {
    name
    posts {
      title
      comments {
        content
      }
    }
  }
}

AI 只需要"看懂这张表"——不需要推理步骤,不需要处理控制流。这相当于从"写一篇小作文"变成了"填一张表格"。

Token 消耗对比:

  • REST:平均需要 300-500 tokens 来描述多端点的组合逻辑
  • GraphQL:仅需 50-100 tokens 来表达同等的查询意图

三、高阶概念融合:GraphQL × AI Agent × OpenClaw

3.1 从 Mutation 到 AI Skills:原子化能力的映射

在 AI Agent 的架构中,一个核心概念是 Skills(技能)——每个技能都是 Agent 可以调用的原子化能力。而 GraphQL 的 Mutation(变更操作) 天然就是这种原子化能力的最佳载体。

举个例子:

type Mutation {
  createPost(title: String!, content: String!): Post!
  deletePost(id: ID!): Boolean!
  likePost(id: ID!): Post!
}

这三个 Mutation 可以直接映射为 AI Agent 的三个 Skills:

{
  "skills": [
    {
      "name": "create_post",
      "input_schema": {
        "title": "string",
        "content": "string"
      },
      "output_schema": "Post"
    },
    {
      "name": "delete_post",
      "input_schema": { "id": "ID" },
      "output_schema": "boolean"
    },
    {
      "name": "like_post",
      "input_schema": { "id": "ID" },
      "output_schema": "Post"
    }
  ]
}

关键洞察:GraphQL 的 Schema 本身就是一份"技能清单"。AI Agent 不需要额外的配置文件,只需要读取 Schema,就能自动获取所有可用的操作能力。


3.2 Introspection:让 AI 实现工具的"自发现"

GraphQL 有一个"杀手级"特性:Introspection(自省) 。你可以向任何 GraphQL 服务查询它自己的 Schema:

query IntrospectionQuery {
  __schema {
    types {
      name
      fields {
        name
        type {
          name
          kind
        }
      }
    }
    queryType { name }
    mutationType { name }
  }
}

这意味着什么?意味着 AI Agent 可以零配置接入任何 GraphQL 服务

  1. Agent 连接到一个 GraphQL 端点
  2. 发起 Introspection 查询,获取完整 Schema
  3. 自动生成可用的 Skills 列表
  4. 根据用户意图动态组合查询

这就是 OpenClaw 架构的核心理念——工具的自发现与动态组合

示例流程:

用户: "帮我查看今天的销售数据,然后生成一份报告"

┌──────────────────────────────────────────────────┐
│  AI Agent 执行流程                                │
└──────────────────────────────────────────────────┘

1. [自省阶段]
   Agent → GraphQL Server: 
     "你有哪些查询能力?"
   
   Server → Agent:
     "我有 salesData(date: Date) 和 
      generateReport(data: SalesData)"

2. [意图推理阶段]
   Agent 分析用户意图:
     - 需要先查询数据
     - 再调用报告生成

3. [执行阶段]
   Agent 构建查询:
     query {
       salesData(date: "2024-02-15") {
         revenue
         orders
       }
     }
   
   Agent 调用 Mutation:
     mutation {
       generateReport(data: $salesData)
     }

4. [返回结果]
   Agent → 用户: "已生成报告,今日营收 ¥12,345"

3.3 语义导航:AI 在业务逻辑中的自动推导

GraphQL 的"图"(Graph)属性不仅仅是命名的巧合——它真的是一张关系图谱。每个类型都通过字段与其他类型连接,形成一张语义网络。

type User {
  id: ID!
  posts: [Post!]!
}

type Post {
  id: ID!
  author: User!
  comments: [Comment!]!
}

type Comment {
  id: ID!
  author: User!
  post: Post!
}

这张图谱告诉 AI:

  • User 可以导航到 Post
  • Post 可以导航到 Comment
  • Comment 可以反向导航回 UserPost

当用户说"找出所有评论过 Alice 文章的用户"时,AI 可以自动推导出查询路径:

User (Alice) → posts → comments → author (其他用户)

并生成查询:

query {
  user(name: "Alice") {
    posts {
      comments {
        author {
          name
        }
      }
    }
  }
}

这种语义导航能力让 AI Agent 能够像人类一样"理解"业务关系,而不是死记硬背端点 URL。


四、工程实践:优势、劣势与迁移路径

4.1 优势总结

维度 GraphQL 的价值
前端自治 前端可以自主决定需要哪些数据,无需等待后端开发新端点
类型安全 强类型系统在编译时捕获错误,减少运行时 Bug
平滑演进 通过 @deprecated 标记废弃字段,支持渐进式迁移
文档自动化 Schema 即文档,工具可自动生成交互式 API Explorer
AI 友好 机器可读的契约,降低 AI 辅助开发的幻觉率

4.2 劣势与应对

问题 1:N+1 查询问题

当你查询一个列表及其关联数据时,可能触发大量数据库查询:

query {
  users {          # 1 次查询
    name
    posts {        # N 次查询(每个用户一次)
      title
    }
  }
}

解决方案:DataLoader 使用批量加载和缓存机制,将 N+1 次查询合并为 2 次:

const postLoader = new DataLoader(async (userIds) => {
  const posts = await db.posts.findByUserIds(userIds);
  // 按 userId 分组返回
});

问题 2:缓存复杂性

REST 的 URL 可以直接用作缓存键,但 GraphQL 的查询体是动态的:

# 两个不同的查询,无法用 URL 缓存
query { user { name } }
query { user { name, email } }

解决方案:持久化查询 + Apollo Cache

  • 为常用查询分配固定 ID
  • 使用规范化缓存(以类型 + ID 为键)

问题 3:初始配置成本

编写 Resolver 和 Schema 需要一定工作量。

但在 AI 时代,这个成本正在消失

  • AI 可以根据数据库表结构自动生成 Schema
  • AI 可以批量生成 Resolver 代码
  • AI 可以识别业务逻辑并建议字段关系

4.3 迁移路径:Wrapper Pattern(包裹模式)

你不需要推翻现有的 REST API。可以用 GraphQL 作为"前端代理",逐步迁移:

// GraphQL Resolver 调用旧 REST API
const resolvers = {
  Query: {
    user: async (_, { id }) => {
      // 调用旧的 REST 端点
      const response = await fetch(`/api/users/${id}`);
      return response.json();
    },
  },
  User: {
    posts: async (user) => {
      // 调用另一个 REST 端点
      const response = await fetch(`/api/posts?user=${user.id}`);
      return response.json();
    },
  },
};

优势:

  • 一夜迁移:前端立即获得 GraphQL 的所有好处
  • 渐进式:后端可以慢慢将 REST 逻辑重构为原生 Resolver
  • 风险可控:出问题可以随时回退到 REST

五、总结:从"编写代码"到"定义契约"

在软件工程的演进中,我们经历了几次范式转移:

  1. 机器码时代:手动编写二进制指令
  2. 高级语言时代:用 C/Java 表达逻辑
  3. 声明式时代:用 SQL/GraphQL 表达意图

而现在,我们正站在第四次转移的门槛上——契约驱动的 AI 协作时代

GraphQL 的价值不再仅仅是"更好的 API",而是成为了人类与 AI 之间的通用协议

  • 人类定义 Schema(业务契约)
  • AI 基于 Schema 生成查询(代码实现)
  • Schema 的变更自动传播到 AI 的理解中

这是一种全新的分工模式:人类负责"定义世界",AI 负责"操作世界"


"如果说 REST 是工业时代的装配线——每个端点都是一个固定的工位,那么 GraphQL 就是 AI 时代的神经系统——每个查询都是一次自主的意图表达。当我们停止告诉机器'该做什么',而是告诉它'世界是什么样的'时,真正的智能协作才刚刚开始。"


延伸阅读

2月14日全社会跨区域人员流动量完成28566.2万人次

36氪获悉,来自综合运输春运工作专班数据显示,2026年2月14日(春运第13天,农历腊月二十七,星期六),全社会跨区域人员流动量28566.2万人次,环比下降0.8%,比2025年同期(星期日)增长7.5%。其中:铁路客运量1529.9万人次,环比持平,比2025年同期增长5.3%。

vue2vue3响应式

响应式基础

vue开篇提到了怎么在vue的选项式写法中声明组件状态,就是在对象中写一个data属性,这个属性要是一个函数,这个函数要返回一个对象,返回的对象会被vue在合适的时候调用赋予它响应的能力,然后vue会把这个对象上的属性都放到组件自身上, 我们再讨论接下来的问题之前c,先展示vue2以及vue3是怎么大致实现响应式的, 帮助理解

vue2响应式

vue2实现响应式的思路就是给对象加setter和getter,把这些属性全部挂载到组件实例对象上, 然后给每个属性添加上setter更新值的时候要触发的响应函数就可以实现响应式了,具体看下面这个js例子

class Dep {
  constructor() {
    this.bukets = [];
  }
  addDep(fn) {
    this.bukets.push(fn);
  }

  notify() {
    this.bukets.forEach((fn) => {
      fn.update();
    });
  }
}


//观察者
class Watcher {
  constructor(obj, name, updateCb) {
    this.updateCb = updateCb;
    this.init(obj, name);
  }
  init(obj, name) {
    //把注册函数送出去,注册好响应式
    Dep.target = this;
    obj[name]; // 触发Dep响应,添加进这个watcher者
    this.update();
    Dep.target = null;
  }

  update() {
    this.updateCb();
  }
}


//定义给对象响应式属性
const defineReactive = (obj, key, val) => {
  //为这个属性实例化一个观察者
  const dep = new Dep();
  Object.defineProperty(obj, key, {
    get() {
      //当触发key时,说明要使用这个依赖
      if (Dep.target) {
        dep.addDep(Dep.target);
      }
      return val;
    },
    set(newVal) {
      val = newVal;
      //通知
      dep.notify();
      
    }
  });
};


<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    #app {
      display: inline-flex;
      column-gap: 10px;
      padding: 10px 12px;
      border-radius: 8px;
      margin: 100px 200px;
      background-color: #f5f5f5;
      cursor: pointer;
      user-select: none;
    }
    #app span {
      display: flex;
      justify-content: center;
      align-items: center;
      width: 20px;
      height: 30px;
      background-color: #ececec;
    }
  </style>
</head>
<body>
  <div id="app">
    <span data-action="sub">-</span>
    <span class="count"></span>
    <span data-action="add">+</span>
  </div>

  <script src="./index.js"></script>
  <script>
    let obj = {};
    defineReactive(obj, "count", 0);
    const countEle = document.querySelector(".count");


    new Watcher(obj, "count", () => {
      countEle.innerText = obj.count;
    });

    document.querySelector("[data-action='sub']").addEventListener("click", () => {
      obj.count--;
    });
    document.querySelector("[data-action='add']").addEventListener("click", () => {
      obj.count++;
    });

  </script>
</body>
</html>



关注我们重点的最开头的四个函数,这就是vue2大致实现响应式的样子,我们可以看到,我们实际上是给data指定的数据使用Object.defineProperty定义了get和set函数, , 然后在初始的时候在get函数里添加上watcher,,在这个属性触发set的时候,我们通知这些watcher使用最新的值进行更新,这就是大致流程, 然后我们再来看看vue3对于响应式是怎么实现的

vue3响应式

let activeFn;

const effect = (fn) => {
  activeFn = fn;
  fn();
  activeFn = null;
};

const buckets = new WeakMap();

const trigger = (target, property) => {
  const depsMap = buckets.get(target);
  if (!depsMap) {
    return ;
  }

  const fns = depsMap.get(property);

  console.log(fns, "fns");
  fns && fns.forEach(fn => fn());
};
const track = (target, property) => {
  let depsMap = buckets.get(target);
  if (!depsMap) {
    buckets.set(target, (depsMap = new Map()));
  }

  let deps = depsMap.get(property);

  if (!deps) {
    depsMap.set(property, (deps = new Set()));
  }

  deps.add(activeFn);
};

const reactive = (data) => {
   return new Proxy(data, {
    set(target, property, newVal, receiver) {
      trigger(target, property);
      return Reflect.set(target, property, newVal, receiver);
    },
    get(target, property, receiver) {
      if (activeFn) {
        console.log(target,property, "target-property");
        track(target, property);
      }
      console.log("触发set");
      return Reflect.get(target, property, receiver);
    }
  });
};
  <style>
    #app {
      display: inline-flex;
      column-gap: 10px;
      padding: 10px 12px;
      border-radius: 8px;
      margin: 100px 200px;
      background-color: #f5f5f5;
      cursor: pointer;
      user-select: none;
    }
    #app span {
      display: flex;
      justify-content: center;
      align-items: center;
      width: 20px;
      height: 30px;
      background-color: #ececec;
    }
  </style>
</head>
<body>
  <div id="app">
    <span data-action="sub">-</span>
    <span class="count"></span>
    <span data-action="add">+</span>
  </div>

  <script src="./index2.js"></script>
  <script>
    let obj = {count: 0};
    obj = reactive(obj);
    const countEle = document.querySelector(".count");

    
    effect(() => {
      countEle.innerText = obj.count;
    });


    document.querySelector("[data-action='sub']").addEventListener("click", () => {
      obj.count--;
    });
    document.querySelector("[data-action='add']").addEventListener("click", () => {
      obj.count++;
    });

  </script>
</body>

我们可以看到,我们基于Proxy实现的响应式系统是现有一个obj对象, 然后我们定义了一个代理对象,我们后续都是操作这个代理对象去实现响应式更新

总结

基于上述描述,我们可以知道,vue2的响应式的确是在原始对象上定义了一个新的属性然后设置get和set,我们在这个对象属性上触发了set的时候,也会触发响应函数更新, 在vue3的时候,是现有原始的对象,我们给这个对象设置了一个代理对象,后续的响应式都是通过触发代理对象的set和get实现的,在代理对象上触发了set的时候,会触发响应函数更新, 完全与原始对象解耦了。同时也可以注意到,我们在vue2的实现中,并没有return 一个函数或者是包含函数的对象,但是我们的属性val,却因为defineProperty的实现而被留存了下来,通过这种形式也实现了一个闭包,所以我们可以说,没有return一个使用了内部变量的函数就不是闭包的说法是错误的,只要实现了将内部变量外泄到外部代码,并且外部代码只能受控的间接访问这个内部变量的这么个现象,我们就可以认为是一个闭包,return一个使用了内部变量的函数只是实现的一个具体方法。

回到Vue文档

查看下面一个vue文档给出的例子

export default {
  data() {
    return {
      someObject: {}
    }
  },
  mounted() {
    const newObject = {}
    this.someObject = newObject

    console.log(newObject === this.someObject) // false
  }
}

当你在复制后再访问this.someObject, 这个时候因为触发了this的set函数,属性是someObject, 所以在vue3中会创建一个新的响应式对象,然后复制给this.someObject,这个对象是代理后的对象,它的原始对象是newObject, 而对于vue2,它会接受这个对象,然后在这个对象上设置getter和setter,把这个对象转换成响应式 由于转换是在同一个对象上进行的 ,所以文档说当你在赋值后再访问this.someObject, 此值已经是原来的newOject的一个响应式代理,与vue2 不同的是,这里的原始的newObject不会变为响应式,请确保始终通过this来访问响应式状态

声明方法

先看下面一个例子

export default {
  data() {
    return {
      count: 0
    }
  },
  methods: {
    increment() {
      this.count++
    }
  },
  mounted() {
    // 在其他方法或是生命周期中也可以调用方法
    this.increment()
  }
}

vue文档在这里说不应该使用箭头函数,因为箭头函数的this值是跟着作用域走了,而在对象中使用 ...() {}, 的形式相当于function () {} ,其中的this是由调用方觉定的,所以这里的methods中的方法使用箭头函数后如果是顶层的箭头函数的this就是window,不会改变

响应式状态新增属性

当我们在vue2的响应式状态上新增一个属性的时候,vue2没有办法检测到变化,查看下面一个例子

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>数组列表渲染重点</title>
</head>

<body>
  <div id="app">
    {{obj.nested.count}}
    {{JSON.stringify(obj.nested)}}
    <button @click="mutateDeeply">增加</button>
  </div>
  <script src="https://cdn.bootcdn.net/ajax/libs/vue/2.6.11/vue.js"></script>
  <script>
    const app = new Vue({
      el: "#app",
      data() {
        return {
          obj: {
            nested: { count: 0 },
          }
        }
      },
      methods: {
        mutateDeeply() {
          // 以下都会按照期望工作
          this.obj.nested.count++
        }
      }
    })
  </script>
</body>

</html>

如果我们在控制台输入app.obj.nested.count2 = 2;可以发现,这个时候我们的页面并没有发生变化,如果我们换成vue3的写法,会怎么样,请查看下面一个例子

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>数组列表渲染重点</title>
</head>

<body>
  <div id="app">
    {{obj.nested.count}}
    {{JSON.stringify(obj.nested)}}
    <button @click="mutateDeeply">增加</button>
  </div>
  <script src="https://cdn.bootcdn.net/ajax/libs/vue/3.5.22/vue.global.min.js"></script>
  <script>
  const app = Vue.createApp({
  data() {
    return {
      obj: {
        nested: { count: 0 },
      }
    }
  },
  methods: {
    mutateDeeply() {
      // 以下都会按照期望工作
      this.obj.nested.count++
    }
  }
}).mount("#app");
  </script>
</body>

</html>

如果我们在上面的这个例子控制台中实时的添加app.obj.nested.count2 = 2;可以看到,页面发生了变化! 这是为什么呢,其实,vue2的响应是基于definePRoperty,这就意味着vue2在实现响应式的时候在统一注册响应式的阶段在对象的属性上定义setter/getter,这个时候新增一个属性,压根就没有给这个对象赋予一个setter/getter,所以也就不会触发setter/getter了,如果是在vue3中,我们使用代理对象,响应是基于整个对象的,如果你新增了一个属性,这个时候就会触发整个对象的getter/setter,然后更新整个页面,所以最后的区别也还是因为vue2的响应式是基于对象属性的,而vue3的响应式是基于整个对象的,这是我们在响应式系统上讨论的vue3和vue2的第二个区别

陈茂波:春节假期访港内地旅客预计达143万人次

香港特区政府财政司司长陈茂波15日发表网志称,今年春节假期期间,预计访港的中国内地旅客量将有143万人次,日均访客人数料按年上升约6%。陈茂波表示,今年以来,访港旅客数字持续增加,从1月1日到2月13日访港旅客人次达723万,比去年同期增加9.6%,海外旅客更显著增加16.4%。(中新网)

几天手搓的Claude Code拓麻歌子火了:成本几乎为0,一句话做硬件时代来了

1996 年,一家日本公司推出了 Tamagotchi(电子宠物)。这个小小的蛋形塑料设备风靡全球,成为一代人的童年记忆。

1997 年,拓麻歌子(Tamagotchi)还让它的创造者日本万代公司,获得了当年的搞笑诺贝尔经济学奖,而原因是,

他们创造了人类供养虚拟宠物的新型经济模式,成功转移了数百万人的工作时间,用于饲养虚拟宠物。

去年八月,万代公司表示,拓麻歌子从 1996 年以来,产量已经达到了一亿台。在那个时代,生产一款这样的产品,大概需要一个工业设计团队、需要电子工程师设计电路板、需要长达一年的开发周期……

2026 年,一个开发者用 AI 做了一个 Tamagotchi。他需要的只是一台电脑和 Claude Code。成本接近零,开发周期可能只有几天。

这个最新的 Claude Code 版拓麻歌子,最近在 X 上吸引了一大波网友的关注。

▲视频来源:https://x.com/SamuelBeek/status/2022614292411940897

网友把命令行里面跳动的 Claude Code 符号,转到了能够触摸得到的、随身携带的拓麻歌子上。当 Claude Code 在命令行里面思考,或者是问,是否同意执行下面的步骤时,手里的拓麻歌子都会弹出消息来,指示我们下一步操作。

电子宠物成精了,还会拦截 Bug

和以前那些 AI 硬件的逻辑不同,Claude Code Tamagotchi 不是一味的把大模型放到布娃娃、手表、闹钟、书包、甚至是马桶里。

这个 Claude Code 拓麻歌子要做的是一种转移,一种无法被替代的存在。

目前已经有多款不同的 AI 拓麻歌子小玩意,其中关注度最高的由开发者 Ido Levi 创建的 Claude Code Tamagotchi。

▲视频来源:https://www.instagram.com/reel/DUMAlN7Dpx7/

乍一看,它就是一只住在终端里的像素风格宠物。有一些简单的表情、有状态、还会对用户的行为做出反应;但它不是一个简单的怀旧游戏。

当我们在用 Claude Code 编程时,放在桌子边上的这只宠物,会一直在你的终端界面中显示。它在观察 Claude Code 的每一个操作,确保这个 AI 助手真的在按照我们的意图工作。

如果 Claude Code 表现良好,宠物会开心地摇尾巴。如果 AI 开始不听话,比如未经允许重构代码,或者修改了你明确说不要动的文件,宠物会变得暴躁,甚至会直接中断 AI 的操作。

▲项目地址:https://github.com/Ido-Levi/claude-code-tamagotchi

目前,Claude Code 拓麻歌子这个宠物项目,已经在 GitHub 上开源,我们也可以直接把这个电子宠物部署到自己的 Claude Code 里面。它具体是如何工作的呢,根据作者对项目的介绍,举几个例子来说明一下。

项目主打的就是「实时监控」,当我们直接对 Claude Code 说,「只修复这个 bug,不要动其他文件。」

Claude Code 开始工作,终端里的宠物睁大眼睛盯着看。几分钟后,Claude Code 完成了修改,只改动了目标文件。
这个小宠物就会开心地摇尾巴:😊 (◕‿◕)。

而当这个小宠物检测到违规时,他还能发出「违规警告」。我们明确告诉 Claude Code 说,不要重构,保持代码原样。但 Claude Code 还是开始重构整个模块,可能它觉得这样代码会更优雅。

这个时候,电子宠物的表情变了:😠;屏幕上还会显示,「⚠ 警告:AI 正在违背你的指示」。

除了提示,它也能实际的做一些越界拦截之类的工作。比如我们给出的指令里面非常明确的提到了,千万不要动数据库。Claude Code 在修复一个相关 bug 时,尝试修改数据库。

小宠物就会立即中断:❌ 操作被阻止。Claude Code 的操作被拦截,我们的数据库安然无恙。宠物露出得意的表情:💪

这种从软件到硬件的交互,也让我想到了我们之前分享的 Vibe Coding 小键盘。

这几天,在 X 上还有一个硬件版 Cursor 特别火。目前的 Cursor 是专门用来开发软件产品的工具,而这个 Cursor for hardware 就是用来实现,一句话做一个硬件设备。

▲ 为硬件开发设计的 Cursor,地址:https://www.schematik.io/

网友 marcvermeeren 就用这个工具,搭建了一个叫做 Clawy 的可爱小助手,用来管理他的 Claude Code 对话。

还有网友 dspillere 也做了一个类似的产品,他说虽然已经部署了 OpenClaw,但他完全不知道 OpenClaw 什么时候在思考,什么时候在执行任务。这个小巧的桌面助手就应运而生,放在他的桌子上,可以实时的更新 OpenClaw 的最新信息。

▲视频来源:https://x.com/dspillere/status/2018752036968304660

在评论区里,大家都在问什么时候发货,可以去哪里买。也有人说,这是一个全新的领域,我们一直在关注人的状态,关注人类的电子使用记录,是时候应该关注 Agent 的情况了。

▲Agent 的物理反馈是一个被严重低估的用户体验问题

软件开发的 AI 红利,终于轮到硬件了

去年,我们还在想 AI 最好的软件载体是什么,是大家都在做的对话框,还是连 OpenAI 都一窝蜂涌进去要重做的浏览器,但最后证明都不是,今年 OpenClaw 的爆火,证明了 AI 在软件上,最终的归宿就是 Agent。

关于硬件的讨论就更不用多说,光是今年 CES 上那些让人哭笑不得的发明,就能看到 AI 硬件这块还是个巨大的未知数。

如果说 Agent 的成功是靠着「人人都能做软件」慢慢成长起来的,那么 AI 硬件也会在「人人都能做硬件」里面,不断沉淀。

▲Schematik 的发起人 Samuel Beek,现为 VEED.io 首席产品官

像 Schematik 这类工具已经设计出来,用来帮助我们更快开发 AI 硬件。它把硬件设计变成了和网页开发一样,我们只需要用自然语言描述硬件需求。告诉 Schematik 想要构建一个「带温度传感器和 OLED 显示屏」,不需要查阅各种数据表,不需要引脚编号、元件代码或任何的手动查找。

过去,如果我们想做一个简单的「温湿度监测器」。需要做的是,

  1. 搜索传感器型号,下载 DataSheet。
  2. 确认引脚定义(VCC 是接 3.3V 还是 5V?接反了直接冒烟)。
  3. 寻找对应的驱动库,处理版本冲突。
  4. 在 Arduino IDE 里写代码,改 Bug。

而 Schematik 的出现,把这个过程极简化成了「一句话的事」。几秒钟后,Schematik 会吐出我们需要的一切。完整的、通过验证的固件代码;一份清晰的接线图;分步组装指南。

它生成的接线图,清晰地展示了每一根线该从哪里接到哪里,解决了新手最大的恐惧,「我这根线接对了吗?」。一键部署的功能,更是一步到位,它能直接生成基于 PlatformIO 的工程文件,直接导入。

PlatformIO 是一个强大的嵌入式开发生态,我们可以直接在 Schematik 里点击「Flash」,固件就会被编译并烧录进板子里。从「我想做一个东西」到「这东西跑起来了」,中间可能只需要不到一分钟。

前段时间,Claude 发布的 Cowork 以及相关企业级 AI 插件重挫软件股,直接蒸发人民币约两万亿。以前我们想要一个 P 图工具,需要去应用商店搜索下载安装,现在,一句话自己都能做一个。

但 Claude Code Tamagotchi 这类产品的出现,还有硬件版 Cursor,让我们不得不怀疑,硬件开发的「Cursor 时刻」是不是也要来了。

未来的硬件开发,或许也会变成,只需要我们提供「创意」和「逻辑」,剩下的脏活累活,无论是写代码还是画电路图,都将由 AI 代劳。

也许这样的未来不会很远。但更重要的是,在这个时代,动手能力的定义已经变了。

以前动手能力强是指一个人会焊接、会画板子、会写代码;以后,动手能力强,是说他擅长用 AI,从从容容、游刃有余地指挥原子和比特为他起舞。

我已经想到了,下一个爆火的 AI 硬件,甚至可能会是一个挂在包上的 OpenClaw 版 Labubu。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

爱范儿 | 原文链接 · 查看评论 · 新浪微博


马斯克预言:2026年底编程或将全面自动化

美国企业家埃隆·马斯克在近日发布的视频中指出,到今年年底,我们甚至不再需要编程,AI将直接编写二进制代码。他预测,随着AI技术的持续发展,人类对编程语言的依赖将会逐渐减弱。(界面)

那些零负债人群,为什么也不花钱消费呢?

最近我经常刷到一个词叫做“零负债人群”,在一些报道中,专家们表示可以撬动这批人来消费,但是我越看越不对劲,然后去研究了一下。这期视频不废话,我们一口气把这个热词“零负债人群”给讲透。

下载虎嗅APP,第一时间获取深度独到的商业科技资讯,连接更多创新人群与线下活动

优步今年将进军欧洲7个新市场

优步派送业务主管苏珊·安德森2月14日表示,现在是时候在欧洲“打破常规”了,优步今年将把配送业务扩展至欧洲七个新国家,包括捷克、希腊、罗马尼亚、奥地利、丹麦、芬兰和挪威市场。(界面)

全网劝退潮汕游 , 我们“外省仔”还能去吗

潮汕为什么火成这样

作者 | 罗立璇 王晓玲

潮汕人小谢今年的回家路费格外昂贵。家住汕头乡下的她,发现北京飞揭阳(潮州、汕头、揭阳三地共用一个机场)的机票,和往年比已经翻倍,甚至还差点没买到回京的平价机票。

“本来以为买黄金赚到钱了,没想到生活在这里伏击我呢”。

实际上,今年潮汕酒店大幅涨价已经成为热门话题。汕头市中心邻近最热门的龙眼南路的亚朵,在大年初一已经涨到超过2000元(平时的价格大约在500元)一晚,有一些房型占到超过4000元。

平台潮汕酒店价格

早在1月28日,汕头就发布了价格提醒告诫书,呼吁酒店经营者合理制定价格,规范经营行为。

平台潮汕酒店价格

在小红书等社交平台上,也出现了调侃的笔记:春节PLAN A是纽约/巴黎/悉尼,PLAN B是潮汕,大家觉得去哪个城市好?评论说:建议没实力不要硬上潮汕。

对此,东莞人罗叔叔表示惊讶:咱老广春节实在太无聊才会自驾游去的地方,居然这么不可高攀了?

潮汕为什么火成这样?

大家意识到潮汕春节年俗的独特性,还得从2023年说起。

正月以后,潮汕的游神活动突然蹿红短视频平台:人们发现,游神也可以变得当代和野生,有一些村落甚至会轰着电音、打着霓虹灯,把神仙请出来用最酷炫的方式游街,变成“赛博游神”。

对流量最敏感的首先是旅游博主。因为潮汕的游神到正月十五才是最高潮,很多在春节假期里看到趋势的博主都奔赴潮汕,还有同时火起来的福州地区拍摄。这也为2024年以后潮汕春节文旅主题的高潮埋下了真正的铺垫。

这里面的独特性在于,潮汕有一套完整、鲜活和全民参与的年俗体系,而不仅仅是单个的舞龙舞狮的活动,这击中了遗憾于“年味越来越淡”的当代人,对传统、团圆、仪式感的深层渴望。

潮汕人过年,大致分为“吃吃吃”和“拜拜拜”。其中祭祖与游神是重点节目活动,潮汕人对此极为重视,无论工作再忙,都会在这两件事上腾出时间参与。

潮汕有多神信仰,这些神明除了佛教、道教的神仙以外,也有不少历史英杰(比如关公),被尊称为“老爷”。“营老爷/迎老爷/辑老爷”,就是祈求神明在新的一年保佑平安,风调雨顺。

游神分为“文游”和“武游”。文游来说,其中有最著名的英歌舞(形式来自于傩戏和秧歌),还有标旗队、花篮队、八宝队、仪仗队等等,形式基本都是节奏稳定的队伍,伴着歌舞,巡游村中。

英歌舞

而武游则是为寻常的游行仪式增加了更多阻碍和惊险元素,让人看起来更加血脉贲张。比如普宁的跳火堆,就需要抬着神明快速跳过火堆。也有澄海的挨砻身,大概就是绕着木桩快速跑圈,大家抬着神明不断轮换加入,同时外围的人燃放鞭炮,场面惊险。

跳火堆

最刺激的,可能是澄海盐鸿的辑老爷。在游神过程中,一支队伍负责抬神明,另一支队伍负责“围攻”神明、扯落神轿,双方对垒。

而从游客的角度看,这些被留存下来的丰富仪式,成为了村村都不同的惊人景观,在今天格外有观赏性。

而且,潮汕还有两个大杀器——气候温暖,美食遍地。位于粤东沿海自不必说,而美食也在这几年达到了声量的历史高峰。首先是《舌尖上的中国》《风味人间》《一饭封神》等S级美食节目里,都有对潮汕菜的推崇。

其次,在北上广深等大城市遍地的潮汕牛肉火锅,把潮汕人追求极致新鲜的“刻板印象”打入了全国食客的脑海里。

此外,原产于潮汕的生腌海鲜,也在这几年走红,成为盒马、山姆等中高端超市预制菜赛道的宠儿。而油柑、芭乐,还有老是被暴打的柠檬等,在连锁茶饮里大放异彩的水果,也是最早被潮汕人挖掘出来,制作成各色产品。

可以说,在旅游目的地层面,潮汕绝对算得上是能让人吃好玩好的全面型选手。

全是“避雷”?

不过,如果你看到这里开始摩拳擦掌、策划去潮汕的春节行程,可能还是要再冷静一下。

和潮汕春节高价酒店相伴出现的搜索结果,还有人山人海。不要忘记,汕头、潮州、揭阳,此前的文旅资源并未进行深度开发,制造和贸易属性远远大于旅游属性,接待能力是相对有限的。

只需要随手在社交平台上一搜,就会发现春节在潮汕旅游的游客都在吐槽,“为什么这么多人?”

社媒吐槽

汕头市中心citywalk路线中最热门的龙眼南路,摩肩接踵不说,还有可能等上三五个小时,才能吃上饭。更甚者,还有可能在早上打车到了市中心以后,晚上甚至找不到车回酒店,只能靠自己腿着回去。“本来想坐公交车,后来发现有些线路的车根本没出现”。

也有人去潮州古城,发现因为人太多,已经进行交通管制。“离古城还有3公里,就得下车走路进去了”。

社媒吐槽

还有人想去距离汕头市区30公里的南澳岛看海景,却发现进出岛的公路,已经挤满了车,“上岛3小时,下岛又是3小时”。

为什么这么堵?一个重要原因是潮汕并没有地铁,公共交通以公交车、出租车、轮渡为主,覆盖中心城和周边。当公路瘫痪的时候,基本上相当于交通瘫痪了。

更重要的是,为什么这么贵?酒店贵,吃饭也比平时贵三四成。这就是一个很简单的供需问题。

潮汕春节旅游的规模增长,远超当地供给的增长速度。

根据《南方日报》数据,2024年春节,潮州和汕头接待的旅客人次,均超500万人次。到了2025年,潮汕三地接待春节旅客超过1500万人次,汕头达到633万人,潮州略微下降到430万人。

2025年,揭阳潮汕机场(也是潮汕地区唯一的机场)的接待量已经突破千万人次,排名全国43名。在2019年,揭阳潮汕机场的年旅客吞吐量为735万人次。

根据《南方日报》的不完全统计,到2025年,当地中高端酒店总量有限,三市合计约120—150家,客房数约1.8—2.2万间,远低于广州等一线城市的供给规模。再考虑到,春节期间需要三倍工资,各项服务费都在上涨,成本也是进一步提高的。

当然了,其中也有很多旅客是返乡、当日游、短途游,但依然无法忽视1.5万间客房与1500万人次之间的巨大鸿沟。

还有很多游客,因为时间有限,只能去最主要的几个景点,观看的也是偏商业性的活动。但其实,很多村子都是初五或者初六开始活动,一直到正月十五达到高潮,正好和公共假期错开了,以至于没有他们在视频里看到的热闹。

潮汕真的不能去吗?

任何一个地方突然成为全国热门旅游目的地,肯定会被挤爆,要不为什么故宫等热门景点要提前预约呢(其实现在潮汕城区的大多数景点也都要预约)。

不过一个区域的容纳能力其实是有弹性的,那就是可以把游客引流到周边乡、镇甚至到村。

这个弹性的大小每个区域都不一样,有的地方弹性很大,例如福建、浙江这些地方,城乡一体化程度很高,住到哪里都很方便,另外就是很多县、镇都有值得打卡的地点。

有的地方弹性很小,或者是只有一个热门景点,周边没有什么可玩的地方,或者是周边食宿条件完全跟不上。

潮汕的弹性可能说极大,也可以说极小。

首先,潮汕地区包括潮州、汕头、揭阳三个城市,本地人会推荐你自驾走完这三个城市。

这三个地方确实都值得一去。不少本地人非常自豪地推荐,揭阳的阳美火把节的可看度一点不亚于营老爷;普宁人则说,最早英歌舞还没红的时候,名字就叫普宁英歌,他们是最有名的。

地图上,三个城市构成了一个很紧凑的三角形。从潮州出发到汕头40多公里,从汕头到揭阳50多公里,揭阳到潮州只有30公里,总里程也不到150公里。不下车的话,自驾一天能转好几圈。

潮汕

但是,如果想在春节顺着这个路线、将行程限定在三个城区中,那估计仍然会遇到食宿紧张的问题。因为潮汕文化名头虽响,但相对来说面积真的不算大。

潮汕地区三个城市总面积约1万多平方公里,常住人口有1000多万。总面积大约是北京的2/3,常住人口也只有北京的一半。和广州相比可能更直观,广州市总面积是7000多平方公里,常住人口接近1900万。

如果对比城区面积的话,差距更加明显。作为一线城市的广州,主城区约为1400平方公里(2024年数据),而潮州只有不到60平方公里。

所以潮汕三个城市能容纳的游客肯定非常有限。我们说潮汕地区容纳能力弹性可以极大,是因为对于那些奔着看英歌或是“营老爷”民俗的人,潮汕真的到处都可以看。

在各种潮汕游的求助贴里,总有当地人指点,潮汕两千多个村子,每个村都会“营老爷”,很多地方都有英歌,何必挤在城市区,看那些商业化的表演。

想看真正的潮汕文化,那么潮州规模最大的营老爷,是青龙古庙全城巡游,但这个巡游是在正月二十四举办,大多数春节前来的人只能错过。

相比这种正式的巡游,潮汕各地小规模的游神赛会,其实更具特色。如果想体验民俗,村里的游神更加自由奔放更加原生态。

两千多个村子能容纳的游客当然非常可观。但是,相当一部分人去潮汕,只是想在一个暖和的地方吃吃喝喝,再看点表演丰富一下行程,而不是去村里吃苦。

为什么去村里会吃苦?因为在这些村子围观“营老爷”,享受不到任何服务,甚至当地人都不想理你。

潮汕人拜老爷,讲究“心诚+仪式正”,一步都不能错。从摆供品、求圣杯开始,无论是文营还是拖神类的武营,全体村民高度投入,不容有错也不容外人干扰。

别说你挤在人群里动弹不得、体验不佳,你要是挡了路,那只能说,后果自负。

在潮汕人的世界观里,神是非常真实且重要的存在。“老爷保号(神仙保佑的意思)”四个字说得可能比“恭喜发财”还多。

潮汕的神也非常多,从玉皇大帝、观音娘娘,到财神爷、妈祖、土地公,以及各乡自己的先贤英杰。潮汕人的一生,从出生到成人礼,再到外出求学、工作、做生意,无不伴随着各路神仙的保佑。

全国各地都有民俗活动,但大多越来越往城市集中。为什么潮汕每个村现在还有自己的祭神活动呢?因为这里每个村的活动,都是村民自己出钱出力啊。

自己出钱出力营老爷,当然不是营给游客看的。

虽然全网都是避雷贴,但潮汕当然不是不能去。如果是想找个舒服的地方过春节,建议做好攻略,判断一下ROI。

如果是奔着深度民俗游,决心进村。那也要先和祠堂、村委里的大爷大叔聊一聊,活动内容、日程都心里有数,几天下来才会有所收获。

总之,没实力不要硬上潮汕!

本文来自微信公众号“20社”,作者:罗立璇 王晓玲,36氪经授权发布。

Pageindex -- 新一代的文档智能检索

PageIndex:无向量推理型 RAG 框架深度解析

传统 RAG 系统依赖向量数据库进行语义检索,但在处理长篇复杂文档时面临上下文丢失、检索不精准等瓶颈。PageIndex 提出了一种全新的「无向量、基于推理」的检索范式,通过层级文档树 + LLM 推理搜索,模拟人类专家阅读文档的方式,实现更精准、可解释的信息检索。


一、传统 RAG 系统的工作流程与痛点

1.1 传统 RAG 的核心流程

文档输入 --> 文本切块(Chunking) --> 向量嵌入(Embedding) --> 存入向量数据库
                                                                |
用户提问 --> 问题向量化 --> 向量相似度检索(Top-K) --> 拼接上下文 --> LLM 生成回答

传统 RAG 系统的关键环节包括:

环节 说明 典型工具
文本切块 将文档按固定大小(如 512 tokens)切分为 chunks LangChain、LlamaIndex
向量嵌入 将每个 chunk 转化为高维向量表示 OpenAI Embedding、BGE、Jina
向量存储 将向量写入专用向量数据库 Pinecone、Milvus、Weaviate、Chroma
语义检索 基于余弦相似度检索最相关的 Top-K chunks FAISS、HNSW 索引
上下文拼接 将检索到的 chunks 拼接为 LLM 的上下文 Prompt 模板

1.2 传统 RAG 的核心痛点

痛点一:文本切块导致上下文割裂

固定大小的切块策略无法感知文档的自然结构,经常在段落中间、甚至句子中间断开,导致:

  • 一个完整的论述被切分到多个 chunk 中,检索时只能拿到片段
  • 表格、公式等结构化内容被粗暴截断
  • 上下文关联信息(如「如前文所述」)丢失引用目标
原始文档:
    第三章 财务分析
    3.1 营收概览
    公司2024年Q3营收为52.3亿元,同比增长15.2%。
    其中,核心业务贡献了38.7亿元(占比74%),
    详细拆分见表3-2。
    [表3-2: 业务线营收拆分]
    ...

切块后:
    Chunk 1: "...公司2024年Q3营收为52.3亿元,同比增长15.2%。其中,核心业务贡献了38.7亿"
    Chunk 2: "元(占比74%),详细拆分见表3-2。[表3-2: 业务线营收拆分]..."
    
    --> 数字被截断,表格引用与表格内容分离
痛点二:语义相似 != 实际相关(氛围检索问题)

向量检索本质上是计算语义空间中的距离,但语义相似并不等于业务相关

  • 问「公司2024年Q3的净利率是多少」,可能检索到2023年的净利率数据(语义高度相似,但年份错误)
  • 问「合同中的违约赔偿条款」,可能返回「合同概述」章节(包含"违约"关键词但并非具体条款)
  • 领域专业术语在通用嵌入模型中的表示不够精确
痛点三:基础设施复杂度高

部署传统 RAG 需要维护一套独立的向量数据库基础设施:

成本项 说明
存储成本 向量索引占用大量内存和磁盘空间
计算成本 嵌入生成需要 GPU 资源,每次文档更新需重新嵌入
运维成本 向量数据库的集群管理、备份、扩缩容
调优成本 chunk_size、overlap、嵌入模型选择等参数需要大量实验
一致性成本 文档更新后,向量索引的增量同步和一致性维护
痛点四:跨引用追踪困难

复杂文档(如财报、法律合同、技术手册)中大量存在内部交叉引用:

  • 「详见附录 A」「参见第 4.2 节」「如表 3-1 所示」
  • 传统 RAG 将文档打散为独立 chunks 后,这些引用关系完全丢失
  • LLM 无法沿着引用链追踪到目标内容
痛点五:检索过程不可解释

向量检索是一个「黑箱」过程:

  • 无法解释为什么返回了某个 chunk 而非另一个
  • 无法提供检索路径和推理依据
  • 在金融、法律、医疗等合规要求高的领域,不可解释性是致命缺陷

二、PageIndex 的核心设计理念

2.1 核心思想:像人类专家一样阅读文档

PageIndex 由 Vectify AI 开发,其核心理念是:

一个人类专家在查阅一份 200 页的财报时,不会把它切成 400 个碎片然后逐个比较相似度。他会先看目录,定位到相关章节,再逐步深入阅读。PageIndex 让 LLM 做同样的事。

2.2 技术架构

                    PageIndex 工作流程

文档输入 --> 结构解析 --> 构建层级文档树(Document Tree)
                              |
                     [根节点: 文档标题与摘要]
                    /          |          \
            [章节1摘要]   [章节2摘要]   [章节3摘要]
             /    \          |          /    \
        [3.1摘要] [3.2摘要]  ...   [小节摘要] [小节摘要]
           |         |                |         |
       [页面内容] [页面内容]       [页面内容] [页面内容]


用户提问 --> LLM 推理 --> 从根节点开始逐层决策 --> 定位到最相关的叶节点 --> 提取精确内容

2.3 三大核心组件

组件一:层级文档树(Hierarchical Document Tree)

PageIndex 将文档转化为一棵语义层级树,而非向量集合:

特性 说明
自然结构保留 章节、小节、段落的层级关系完整保留
节点摘要 每个节点包含对应内容的 LLM 生成摘要
页面对齐 叶节点与原文页面精确对应,支持页码引用
动态深度 树的深度根据文档实际结构自适应调整
组件二:LLM 推理检索(Reasoning-based Retrieval)

检索过程不再是向量距离计算,而是一个多步推理过程:

用户提问: "公司2024年Q3的研发费用率是多少?"

推理步骤:
  Step 1: [根节点] 阅读文档整体摘要,判断这是一份季度财报
  Step 2: [章节级] 在"经营分析""财务报表""管理层讨论"中选择 --> "财务报表"
  Step 3: [小节级] 在"利润表""资产负债表""现金流量表"中选择 --> "利润表"
  Step 4: [页面级] 定位到利润表中包含"研发费用"行项的具体页面
  Step 5: [提取] 提取研发费用金额和营收金额,计算费用率

检索路径: 根 --> 财务报表 --> 利润表 -->47
组件三:可追溯引用系统

每次检索都生成完整的推理链路,包含:

  • 每一步的决策依据
  • 最终答案的来源页码和章节
  • 支撑信息的原文引用

三、PageIndex vs 传统向量 RAG:全面对比

3.1 架构层面对比

对比维度 传统向量 RAG PageIndex
索引方式 向量嵌入 + 向量数据库 层级文档树
文档处理 固定大小切块 按自然结构组织
检索机制 余弦相似度 Top-K LLM 推理树搜索
检索依据 语义距离(数学计算) 逻辑推理(类人决策)
上下文保留 局部(单个 chunk 内) 全局(沿树路径保留层级上下文)
可解释性 低(向量距离难以解释) 高(每步推理路径透明)
跨引用支持 不支持 支持沿树结构追踪引用

3.2 工程层面对比

对比维度 传统向量 RAG PageIndex
依赖组件 嵌入模型 + 向量数据库 + 应用层 LLM + 文档解析器
基础设施 需要部署和维护向量数据库集群 无需额外数据库
参数调优 chunk_size、overlap、top_k、嵌入模型 树结构生成策略
文档更新 需要重新嵌入并更新向量索引 重新生成文档树
部署复杂度 高(多组件协调) 低(单一流程)
成本结构 存储 + 计算(嵌入 + 检索) 计算(LLM 推理调用)

3.3 效果层面对比

以 FinanceBench 金融文档分析基准测试为例:

系统 准确率 说明
PageIndex (Mafin 2.5) 98.7% 基于推理的文档树检索
GPT-4o(直接回答) ~60-70% 无 RAG 增强
传统向量 RAG + GPT-4o ~75-85% 标准向量检索流程

FinanceBench 是由 Patronus AI 联合 Contextual AI 和斯坦福大学开发的金融文档问答基准,包含超过 10000 个专家标注的问答对,涵盖信息查找、数值推理和逻辑推断等任务类型。


四、PageIndex 解决的核心问题

4.1 解决「切块导致的信息损失」

问题本质:传统 RAG 的切块策略是一个「有损压缩」过程,不可避免地破坏文档的完整性。

PageIndex 方案:保留文档自然结构,按章节/小节/页面组织信息,每个节点都包含完整的上下文。

传统 RAG:  文档 --> [chunk1] [chunk2] [chunk3] ... [chunkN]  (信息碎片化)
PageIndex: 文档 --> 树状结构(章节 > 小节 > 页面)              (结构完整保留)

4.2 解决「语义相似 != 实际相关」

问题本质:向量检索衡量的是语义空间中的距离,而非业务逻辑上的相关性。

PageIndex 方案:LLM 在推理过程中理解问题的真实意图,通过逻辑判断而非数学距离来定位信息。

例如,面对问题「2024年Q3净利率」:

  • 向量检索可能返回:2023年Q3净利率数据(语义高度相似)
  • PageIndex 推理:先定位到2024年Q3财报章节,再在利润表中查找(逻辑精确匹配)

4.3 解决「检索不可解释」

问题本质:在合规要求严格的行业(金融、法律、医疗),不可解释的检索结果不可接受。

PageIndex 方案:每次检索生成完整的推理路径,标注来源页码和章节编号,支持人工审核和验证。

检索报告:
  问题: "合同中关于知识产权归属的约定是怎样的?"
  推理路径: 合同全文 --> 第五章 知识产权 --> 5.2 权利归属 -->23-24页
  来源引用: "第5.2条 权利归属:甲方在合同期间完成的所有..."
  置信度: 高(精确匹配到专属条款)

4.4 解决「基础设施复杂度」

问题本质:向量数据库是一个独立的技术栈,增加了架构复杂度和运维负担。

PageIndex 方案

传统 RAG 技术栈 PageIndex 技术栈
应用服务 应用服务
嵌入模型服务 --
向量数据库(Pinecone/Milvus) --
文档解析器 文档解析器
LLM 服务 LLM 服务
共 5 个组件 共 3 个组件

4.5 解决「跨引用追踪」

问题本质:复杂文档中的交叉引用是理解文档的关键,但切块后引用关系完全丢失。

PageIndex 方案:树状结构天然支持引用追踪。当 LLM 在某个节点遇到「详见第 X 章」时,可以沿树结构导航到目标节点继续阅读。


五、PageIndex 的适用场景与局限

5.1 最佳适用场景

场景 原因
金融报告分析 文档结构严谨,需要精确数值提取和多步推理
法律合同审查 存在大量交叉引用,需要逐条追溯
技术手册查阅 多层级目录结构,需要按章节定位
学术论文分析 段落引用关系复杂,需要上下文完整性
监管合规审查 对可解释性和可追溯性有严格要求

5.2 局限性

局限 说明
大规模多文档检索 树搜索适合单文档深度分析,跨数万篇文档检索时,向量检索的效率优势明显
非结构化文档 对于缺乏清晰结构的文档(如聊天记录、碎片笔记),树构建效果受限
LLM 调用成本 每次检索需要多步 LLM 推理调用,token 消耗高于单次向量检索
实时性要求 多步推理的延迟高于向量检索的毫秒级响应
文档质量依赖 树结构的质量取决于原始文档的结构清晰度

5.3 何时选择哪种方案

选择 PageIndex 的场景:
  - 单文档或少量文档深度分析
  - 对准确率和可解释性要求极高(如金融、法律)
  - 文档结构清晰且层级分明
  - 需要跨引用追踪能力
  - 希望简化基础设施栈

选择传统向量 RAG 的场景:
  - 大规模知识库检索(数万至数百万文档)
  - 需要毫秒级响应延迟
  - 文档类型多样且结构不统一
  - 需要跨文档语义关联
  - 成本敏感(LLM 推理费用较高)

六、总结

PageIndex 代表了 RAG 技术演进的一个重要方向,其核心贡献在于:

  1. 范式转换:从「向量相似度检索」转向「LLM 推理检索」,更贴近人类理解文档的方式
  2. 结构保留:用层级文档树取代碎片化切块,从根本上解决上下文丢失问题
  3. 可解释性:每次检索都有清晰的推理路径,满足合规和审计需求
  4. 架构简化:去除向量数据库依赖,降低系统复杂度

传统向量 RAG 和 PageIndex 并非简单的替代关系,而是在不同场景下各有优势。对于需要高精度、可解释、深度文档分析的专业场景,PageIndex 提供了一种更优雅的解决方案;对于大规模、低延迟、跨文档语义搜索的场景,传统向量 RAG 仍然是更实际的选择。

两种方案的融合(如用向量检索做粗筛,用 PageIndex 做精读)也是值得探索的方向,可以兼顾效率和精确度。


Crontab Cheatsheet

Cron Format

Use five time fields followed by the command.

Format Description
* * * * * command min hour day month weekday command
* Any value
, List of values (for example 1,15)
- Range of values (for example 1-5)
/ Step values (for example */10)

Time Fields

Valid ranges for each cron field.

Field Allowed Values
Minute 0-59
Hour 0-23
Day of month 1-31
Month 1-12 or JAN-DEC
Day of week 0-7 (0 and 7 are Sunday) or SUN-SAT

Special Schedule Strings

Shortcuts for common schedules.

String Equivalent Description
@reboot N/A Run once at startup
@yearly 0 0 1 1 * Run once a year
@annually 0 0 1 1 * Same as @yearly
@monthly 0 0 1 * * Run once a month
@weekly 0 0 * * 0 Run once a week
@daily 0 0 * * * Run once a day
@midnight 0 0 * * * Same as @daily
@hourly 0 * * * * Run once an hour

Common Schedules

Frequently used cron expressions.

Schedule Cron Expression
Every minute * * * * *
Every 5 minutes */5 * * * *
Every 15 minutes */15 * * * *
Every hour at minute 0 0 * * * *
Every day at 02:30 30 2 * * *
Every weekday at 09:00 0 9 * * 1-5
Every Sunday at 03:00 0 3 * * 0
First day of month at midnight 0 0 1 * *
Every 6 hours 0 */6 * * *
Every month on day 15 at 06:00 0 6 15 * *

Crontab Management

Create, list, and remove per-user cron jobs.

Command Description
crontab -e Edit current user’s crontab
crontab -l List current user’s crontab
crontab -r Remove current user’s crontab
crontab -u username -l List another user’s crontab (root)
crontab -u username -e Edit another user’s crontab (root)
crontab file.txt Install crontab from file

Command Patterns

Useful patterns for reliable cron jobs.

Pattern Description
*/5 * * * * /path/script.sh Run script every 5 minutes
0 2 * * * /path/backup.sh >> /var/log/backup.log 2>&1 Append stdout/stderr to a log
0 1 * * * /usr/bin/flock -n /tmp/job.lock /path/job.sh Prevent overlapping runs
@reboot /usr/bin/sleep 30 && /path/startup.sh Run shortly after boot
MAILTO=\"admin@example.com\" Send job output by email

Environment in Cron

Define environment values at the top of crontab.

Entry Description
SHELL=/bin/bash Use Bash for job execution
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin Set explicit command path
MAILTO=\"\" Disable cron email
CRON_TZ=Europe/Skopje Set timezone for this crontab

Troubleshooting Checks

Quick checks when jobs do not run.

Check Command
Validate cron service status systemctl status cron or systemctl status crond
Check cron logs (Debian/Ubuntu) grep CRON /var/log/syslog
Check cron logs (RHEL/Fedora) grep CROND /var/log/cron
Check script permissions ls -l /path/script.sh
Test script manually /path/script.sh
Check if @reboot ran journalctl -u cron --since "today"

Related Guides

Use these articles for complete cron workflows.

Guide Description
Scheduling Cron Jobs with Crontab Full guide to creating and managing cron jobs
How to List Cron Jobs in Linux View user and system cron jobs
Cron Jobs Every 5, 10, 15 Minutes Ready-made recurring interval examples
❌