普通视图

发现新文章,点击刷新页面。
今天 — 2026年5月11日技术

dd Command in Linux: Copy Disks, Partitions, and Files

Most of the time when you copy a file on Linux, you reach for cp. It walks the filesystem, respects permissions, and works on individual files. But when you need to copy an entire disk, write an ISO image to a USB stick, or create a byte-for-byte backup of a partition, the filesystem view is not enough. For that, Linux ships with dd, a tool that copies raw data block by block between any two files or devices.

This guide explains how to use dd safely to write ISO images, clone disks, back up partitions, create test files, and benchmark storage performance.

dd Syntax

The general form of the dd command is:

txt
dd if=INPUT of=OUTPUT [OPTIONS]

The two key operands are if (input file) and of (output file). Either one can be a regular file, a block device such as /dev/sda, or even /dev/zero and /dev/urandom. Options control the block size, how many blocks to copy, and what conversions to apply.

Warning
dd writes exactly what you tell it to write, to exactly the target you point it at. Pointing of= at the wrong disk will overwrite that disk without warning or confirmation. Always double-check the target device with lsblk or sudo fdisk -l before running a dd command.

Writing an ISO Image to a USB Drive

The most common use for dd is flashing a Linux ISO to a USB drive so you can boot and install from it. First, identify the USB device with lsblk or sudo fdisk -l:

Terminal
lsblk
output
NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS
sda 8:0 0 465.8G 0 disk
├─sda1 8:1 0 512M 0 part /boot/efi
└─sda2 8:2 0 465.3G 0 part /
sdb 8:16 1 14.5G 0 disk
└─sdb1 8:17 1 14.5G 0 part

Here sda is the system disk and sdb is the USB drive. Make sure the USB is unmounted before writing to it:

Terminal
sudo umount /dev/sdb1

Then write the ISO image:

Terminal
sudo dd if=ubuntu-24.04-desktop-amd64.iso of=/dev/sdb bs=4M status=progress oflag=sync

Notice that the output target is the whole disk (/dev/sdb), not a partition (/dev/sdb1). Writing an ISO to a partition would leave the USB unbootable. The options do the following:

  • bs=4M - Read and write 4 MB at a time, which is much faster than the 512-byte default.
  • status=progress - Print a live progress indicator so you can see how much has been written.
  • oflag=sync - Flush every write to the device, so the final byte count reflects data actually on disk.

When dd finishes, run sync once more to make sure all buffered writes hit the USB stick before you remove it:

Terminal
sync

For alternative methods and distro-specific tips, see the guide on how to create a bootable Linux USB drive .

Cloning a Whole Disk

To make an exact block-level copy of one disk onto another, point if= and of= at two different block devices:

Terminal
sudo dd if=/dev/sda of=/dev/sdb bs=64K conv=noerror,sync status=progress

The destination disk must be at least as large as the source. Anything already on it is overwritten. The conversion flags handle read errors gracefully:

  • conv=noerror - Keep going when a read error occurs instead of aborting.
  • conv=sync - Pad short reads with zeros so the output stays aligned with the source.

Clone offline whenever possible. Cloning a disk that is actively being written to produces an inconsistent copy, especially for databases and journaling filesystems.

Backing Up a Partition to an Image File

dd can also write a partition or whole disk to a regular file. That file becomes a bit-for-bit image you can restore later:

Terminal
sudo dd if=/dev/sda1 of=/backup/sda1.img bs=4M status=progress

To save space, pipe the output through gzip:

Terminal
sudo dd if=/dev/sda1 bs=4M status=progress | gzip -c > /backup/sda1.img.gz

The compressed image is much smaller for filesystems that contain text or empty space, though the compression step adds CPU time.

To restore the image to a partition:

Terminal
gunzip -c /backup/sda1.img.gz | sudo dd of=/dev/sda1 bs=4M status=progress

The target partition must be unmounted during restore, and it must be at least as large as the original.

Backing Up and Restoring the MBR

The Master Boot Record sits in the first 512 bytes of a disk that uses a traditional BIOS partition table. You can back it up with dd:

Terminal
sudo dd if=/dev/sda of=/backup/mbr.img bs=512 count=1

To restore the MBR, swap the input and output:

Terminal
sudo dd if=/backup/mbr.img of=/dev/sda bs=512 count=1

On modern UEFI systems the partition table uses GPT instead of MBR, so this trick only applies to older BIOS installations. For GPT disks, use sgdisk --backup and sgdisk --load-backup from the gdisk package.

Creating an Empty File of a Specific Size

dd is often used to create test files of a known size. The count option sets how many blocks to write, and bs sets the block size:

Terminal
dd if=/dev/zero of=testfile bs=1M count=100

This creates a 100 MB file filled with zeros, useful for testing backups, simulating quota limits, or preparing a swap file. To create a file filled with random data instead, read from /dev/urandom:

Terminal
dd if=/dev/urandom of=random.bin bs=1M count=10

Random data is slower to generate because the kernel has to produce the bytes, while /dev/zero is effectively free.

Creating a Swap File

One practical use of the empty-file pattern is creating a swap file :

Terminal
sudo dd if=/dev/zero of=/swapfile bs=1M count=2048 status=progress
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile

The chmod 600 step keeps the swap file readable only by root, which is required by mkswap on most distros. After swapon, the 2 GB swap file is active immediately. Add an entry to /etc/fstab to make it permanent.

Wiping a Disk

To destroy data on a disk before disposing of it or repurposing it, overwrite the entire device with zeros:

Terminal
sudo dd if=/dev/zero of=/dev/sdb bs=4M status=progress

For a stronger wipe that makes recovery harder on traditional spinning disks, use random data:

Terminal
sudo dd if=/dev/urandom of=/dev/sdb bs=4M status=progress

On SSDs, a dd wipe is not the right tool. SSDs remap blocks internally, so a full write does not guarantee every cell was overwritten. Use blkdiscard or the drive vendor’s secure-erase utility instead.

Benchmarking Disk Write Speed

dd is a quick way to measure sustained write throughput. Write a 1 GB file of zeros and let dd report the rate:

Terminal
dd if=/dev/zero of=tempfile bs=1M count=1024 oflag=direct
output
1024+0 records in
1024+0 records out
1073741824 bytes (1.1 GB, 1.0 GiB) copied, 3.1 s, 346 MB/s

The oflag=direct flag bypasses the page cache, so the number reflects actual disk speed rather than memory throughput. For a read benchmark, drop the caches first and then read the file back:

Terminal
sudo sh -c "sync && echo 3 > /proc/sys/vm/drop_caches"
dd if=tempfile of=/dev/null bs=1M

Remove the test file when you are done:

Terminal
rm tempfile

For more detailed I/O profiling, tools like fio and ioping produce more accurate, repeatable results.

Showing Progress on a Running dd

If you started dd without status=progress, you can still ask it to print a status line by sending the USR1 signal to its process. From another terminal, run:

Terminal
sudo kill -USR1 $(pidof dd)

dd will respond by printing the number of bytes copied so far and its current rate, then keep running.

Common Options

A quick rundown of the options covered in this guide, plus a few more worth knowing:

  • if=FILE - Input file or device.
  • of=FILE - Output file or device.
  • bs=N - Block size for both reads and writes (e.g., 4M for 4 MB).
  • ibs=N / obs=N - Separate input and output block sizes.
  • count=N - Number of input blocks to copy.
  • skip=N - Skip N blocks at the start of the input.
  • seek=N - Skip N blocks at the start of the output.
  • status=progress - Print progress while copying.
  • conv=noerror - Continue past read errors.
  • conv=sync - Pad short reads with zeros to keep block alignment.
  • oflag=direct - Bypass the kernel page cache on write.
  • oflag=sync - Flush each write to the device before continuing.

Quick Reference

Command Description
sudo dd if=image.iso of=/dev/sdX bs=4M status=progress oflag=sync Write an ISO image to a USB drive
sudo dd if=/dev/sda of=/dev/sdb bs=64K conv=noerror,sync status=progress Clone one disk to another
sudo dd if=/dev/sda1 of=/backup/sda1.img bs=4M status=progress Back up a partition to an image file
sudo dd if=/dev/sda bs=4M status=progress | gzip -c > disk.img.gz Compressed disk image backup
sudo dd if=/dev/sda of=/backup/mbr.img bs=512 count=1 Back up the MBR
dd if=/dev/zero of=testfile bs=1M count=100 Create a 100 MB file of zeros
sudo dd if=/dev/zero of=/dev/sdX bs=4M status=progress Wipe a disk with zeros
dd if=/dev/zero of=tempfile bs=1M count=1024 oflag=direct Benchmark disk write speed
sudo kill -USR1 $(pidof dd) Ask a running dd for its progress

Troubleshooting

dd: failed to open ‘/dev/sdX’: Permission denied
Writing to a block device requires root privileges. Prefix the command with sudo. If you are already using sudo, double-check that the device path is correct and that the device is not exclusively held by another process.

dd: error writing ‘/dev/sdX’: No space left on device
The destination is smaller than the source. When cloning, the target disk or partition must be at least as large as the source. When writing an ISO, the USB drive must be larger than the ISO file.

The write is much slower than expected
The default block size of 512 bytes forces millions of tiny syscalls. Set bs=4M or bs=64K to speed things up. Also check whether other processes are doing heavy I/O on the same device.

The USB does not boot after writing the ISO
Verify that you wrote the image to the disk (/dev/sdb), not a partition (/dev/sdb1). Also confirm the ISO download with its SHA256 checksum; a truncated or corrupted download produces an unbootable stick.

dd: invalid number: ‘4M’
Older or minimal systems may ship a dd that does not accept the M, G, or K suffixes. On those systems, spell out the block size in bytes (bs=4194304 for 4 MB), or install GNU coreutils.

FAQ

What does dd stand for?
The name comes from the IBM Job Control Language statement “Data Definition.” Because of how often the command is used to overwrite disks by mistake, many Linux users jokingly call it “disk destroyer.” Either way, the behavior is the same: it copies bytes from input to output without touching the filesystem layer.

Is dd faster than cp for copying large files?
For regular files on a normal filesystem, cp is usually just as fast and safer to use. dd only wins when you are copying raw devices, working with exact byte offsets, or deliberately limiting how much data is copied with count.

Can I use dd on an SSD without damaging it?
Reading and writing with dd is fine. The concern with SSDs is full-disk wipes: a single pass of zeros is enough to erase visible data, but repeated full-drive writes wear out the flash cells. For secure erase, use blkdiscard or the drive’s built-in secure-erase command.

How do I see how much data dd has copied?
Add status=progress to the command, or send the running process the USR1 signal with sudo kill -USR1 $(pidof dd). Both methods print the byte count and current throughput without interrupting the copy.

Why use bs=4M instead of the default?
The default block size of 512 bytes means dd issues a separate read and write for every 512 bytes of data. Larger block sizes such as 4 MB dramatically reduce syscall overhead and let the kernel fill the disk pipeline efficiently, often cutting copy times by a factor of ten.

Conclusion

dd is one of the most direct tools in the Linux toolbox. It copies bytes, no more and no less, between any two files or devices. That power makes it indispensable for flashing installers, cloning disks, and building image backups, and the same power makes it unforgiving if you point it at the wrong target.

For related disk tools, see the guides on fdisk for partitioning and df for checking free space.

每日一题-分割数组中数字的数位🟢

2026年5月11日 00:00

给你一个正整数数组 nums ,请你返回一个数组 answer ,你需要将 nums 中每个整数进行数位分割后,按照 nums 中出现的 相同顺序 放入答案数组中。

对一个整数进行数位分割,指的是将整数各个数位按原本出现的顺序排列成数组。

  • 比方说,整数 10921 ,分割它的各个数位得到 [1,0,9,2,1] 。

 

示例 1:

输入:nums = [13,25,83,77]
输出:[1,3,2,5,8,3,7,7]
解释:
- 分割 13 得到 [1,3] 。
- 分割 25 得到 [2,5] 。
- 分割 83 得到 [8,3] 。
- 分割 77 得到 [7,7] 。
answer = [1,3,2,5,8,3,7,7] 。answer 中的数字分割结果按照原数字在数组中的相同顺序排列。

示例 2:

输入:nums = [7,1,3,9]
输出:[7,1,3,9]
解释:nums 中每个整数的分割是它自己。
answer = [7,1,3,9] 。

 

提示:

  • 1 <= nums.length <= 1000
  • 1 <= nums[i] <= 105

2553. 分割数组中数字的数位

作者 stormsunshine
2024年1月28日 13:37

解法

思路和算法

这道题要求将给定的正整数数组 $\textit{nums}$ 中的所有正整数按数位分割,保持数位顺序存入答案数组。需要首先遍历数组 $\textit{nums}$ 得到数位总数,然后将每个正整数按数位分割并存入答案数组。

首先遍历数组 $\textit{nums}$ 得到所有正整数的数位总数 $\textit{totalLength}$,然后创建长度为 $\textit{totalLength}$ 的答案数组 $\textit{answer}$,再次遍历数组 $\textit{nums}$,遍历过程中维护答案数组的当前下标 $\textit{index}$,对于每个正整数,执行如下操作。

  1. 用 $\textit{start}$ 表示当前正整数的数位填入答案数组的起始下标,$\textit{start} = \textit{index}$。

  2. 每次将 $\textit{num}$ 的最低位填入 $\textit{answer}[\textit{index}]$,然后将 $\textit{index}$ 的值增加 $1$,重复该操作直到 $\textit{num}$ 的所有位都填入答案数组。

  3. 当前正整数 $\textit{num}$ 填入答案数组的下标范围是 $[\textit{start}, \textit{index} - 1]$,为按照数位从低到高的顺序填入。为了和数组 $\textit{nums}$ 中的数位顺序保持一致,需要将答案数组的下标范围 $[\textit{start}, \textit{index} - 1]$ 的子数组翻转。

遍历结束之后,即可得到答案数组。

代码

###Java

class Solution {
    public int[] separateDigits(int[] nums) {
        int totalLength = 0;
        for (int num : nums) {
            totalLength += getLength(num);
        }
        int[] answer = new int[totalLength];
        int index = 0;
        for (int num : nums) {
            int start = index;
            int temp = num;
            while (temp != 0) {
                answer[index] = temp % 10;
                index++;
                temp /= 10;
            }
            reverse(answer, start, index - 1);
        }
        return answer;
    }

    public int getLength(int num) {
        int length = 0;
        while (num != 0) {
            length++;
            num /= 10;
        }
        return length;
    }

    public void reverse(int[] answer, int start, int end) {
        for (int i = start, j = end; i < j; i++, j--) {
            int temp = answer[i];
            answer[i] = answer[j];
            answer[j] = temp;
        }
    }
}

###C#

public class Solution {
    public int[] SeparateDigits(int[] nums) {
        int totalLength = 0;
        foreach (int num in nums) {
            totalLength += GetLength(num);
        }
        int[] answer = new int[totalLength];
        int index = 0;
        foreach (int num in nums) {
            int start = index;
            int temp = num;
            while (temp != 0) {
                answer[index] = temp % 10;
                index++;
                temp /= 10;
            }
            Reverse(answer, start, index - 1);
        }
        return answer;
    }

    public int GetLength(int num) {
        int length = 0;
        while (num != 0) {
            length++;
            num /= 10;
        }
        return length;
    }

    public void Reverse(int[] answer, int start, int end) {
        for (int i = start, j = end; i < j; i++, j--) {
            int temp = answer[i];
            answer[i] = answer[j];
            answer[j] = temp;
        }
    }
}

###C++

class Solution {
public:
    vector<int> separateDigits(vector<int>& nums) {
        int totalLength = 0;
        for (int num : nums) {
            totalLength += getLength(num);
        }
        vector<int> answer(totalLength);
        int index = 0;
        for (int num : nums) {
            int start = index;
            int temp = num;
            while (temp != 0) {
                answer[index] = temp % 10;
                index++;
                temp /= 10;
            }
            reverse(answer, start, index - 1);
        }
        return answer;
    }

    int getLength(int num) {
        int length = 0;
        while (num != 0) {
            length++;
            num /= 10;
        }
        return length;
    }

    void reverse(vector<int>& answer, int start, int end) {
        for (int i = start, j = end; i < j; i++, j--) {
            swap(answer[i], answer[j]);
        }
    }
};

###Python

class Solution:
    def separateDigits(self, nums: List[int]) -> List[int]:
        answer = []
        index = 0
        for num in nums:
            start = index
            temp = num
            while temp != 0:
                answer.append(temp % 10)
                index += 1
                temp //= 10
            self.reverse(answer, start, index - 1)
        return answer

    def getLength(self, num: int) -> int:
        length = 0
        while num != 0:
            length += 1
            num //= 10
        return length

    def reverse(self, answer: List[int], start: int, end: int) -> None:
        i, j = start, end
        while i < j:
            answer[i], answer[j] = answer[j], answer[i]
            i += 1
            j -= 1

###C

int getLength(int num) {
    int length = 0;
    while (num != 0) {
        length++;
        num /= 10;
    }
    return length;
}

void reverse(int* answer, int start, int end) {
    for (int i = start, j = end; i < j; i++, j--) {
        int temp = answer[i];
        answer[i] = answer[j];
        answer[j] = temp;
    }
}

int* separateDigits(int* nums, int numsSize, int* returnSize) {
    int totalLength = 0;
    for (int i = 0; i < numsSize; i++) {
        totalLength += getLength(nums[i]);
    }
    int* answer = (int*) malloc(sizeof(int) * totalLength);
    *returnSize = totalLength;
    int index = 0;
    for (int i = 0; i < numsSize; i++) {
        int start = index;
        int temp = nums[i];
        while (temp != 0) {
            answer[index] = temp % 10;
            index++;
            temp /= 10;
        }
        reverse(answer, start, index - 1);
    }
    return answer;
}

###Go

func separateDigits(nums []int) []int {
    totalLength := 0
    for _, num := range nums {
        totalLength += getLength(num)
    }
    answer := make([]int, totalLength)
    index := 0
    for _, num := range nums {
        start := index
        temp := num
        for temp != 0 {
            answer[index] = temp % 10
            index++
            temp /= 10
        }
        reverse(answer, start, index - 1)
    }
    return answer
}

func getLength(num int) int {
    length := 0
    for num != 0 {
        length++
        num /= 10
    }
    return length
}

func reverse(answer []int, start int, end int) {
    for i, j := start, end; i < j; i, j = i + 1, j - 1 {
        answer[i], answer[j] = answer[j], answer[i]
    }
}

###JavaScript

var separateDigits = function(nums) {
    let totalLength = 0;
    for (let num of nums) {
        totalLength += getLength(num);
    }
    let answer = new Array(totalLength);
    let index = 0;
    for (let num of nums) {
        let start = index;
        let temp = num;
        while (temp !== 0) {
            answer[index] = temp % 10;
            index++;
            temp = Math.floor(temp / 10);
        }
        reverse(answer, start, index - 1);
    }
    return answer;
};

var getLength = function(num) {
    let length = 0;
    while (num !== 0) {
        length++;
        num = Math.floor(num / 10);
    }
    return length;
};

var reverse = function(answer, start, end) {
    for (let i = start, j = end; i < j; i++, j--) {
        let temp = answer[i];
        answer[i] = answer[j];
        answer[j] = temp;
    }
};

###TypeScript

function separateDigits(nums: number[]): number[] {
    let totalLength = 0;
    for (let num of nums) {
        totalLength += getLength(num);
    }
    let answer = new Array(totalLength);
    let index = 0;
    for (let num of nums) {
        let start = index;
        let temp = num;
        while (temp !== 0) {
            answer[index] = temp % 10;
            index++;
            temp = Math.floor(temp / 10);
        }
        reverse(answer, start, index - 1);
    }
    return answer;
};

function getLength(num: number): number {
    let length = 0;
    while (num !== 0) {
        length++;
        num = Math.floor(num / 10);
    }
    return length;
};

function reverse(answer: number[], start: number, end: number): void {
    for (let i = start, j = end; i < j; i++, j--) {
        let temp = answer[i];
        answer[i] = answer[j];
        answer[j] = temp;
    }
};

复杂度分析

  • 时间复杂度:$O(n \log_{10} m)$,其中 $n$ 是数组 $\textit{nums}$ 的长度,$m$ 是数组 $\textit{nums}$ 的最大元素。计算答案数组的长度与将数位填入答案数组的时间是 $O(n \log_{10} m)$。

  • 空间复杂度:$O(1)$。注意返回值不计入空间复杂度。

倒序插入

2023年3月9日 13:24

这个刚开始写的时候担心数组的长度和时间复杂度的原因,担心通过不了,结果没想过通过了。
我是用一个result数组来装将返回的分割数位值,考虑他们的长度,每一个元素的大小在10的五次方以内,分割之后100000共有六位,也就是最大是6,加上数组的长度最大是1000,于是我就把数组的大小取为6*10000=60000.
虽然我也知道浪费空间,但是我目前没有更好的方法,希望有大佬能够指点。

确定了返回数组了,然后就是然后把原始的整数拆分插入到结果数组中,后面我想到只要逐个不断求余就可以得到每一位数,后面我就在第一层遍历中添加一个循环得到整数的各个数位,但是提交的结果不符合要求,题目要求将数位按原本出现的顺序排列成数组,于是我就用一个数组作为辅助空间,将它反向添加,于是就得到了和题目意思相同的结果,辅助空间的大小就是一个整数拥有的各个位数的数量,由前面可以知道最大是6。

之后返回即可。

int* separateDigits(int* nums, int numsSize, int* returnSize){
    int *result=(int*)malloc(sizeof(int)*60000);
    int count=0;
    //遍历整个数组
    for(int i=0;i<numsSize;i++){
        int tmp=nums[i];
        int help[6];
        int tmpCount=0;
        //得到整数的各个数位,将他们储存在一个辅助数组中
        while(tmp){
            help[tmpCount++]=tmp%10;
            tmp=tmp/10;
        }
        //把数组中的元素倒序添加到结果数组中
        for(int j=tmpCount-1;j>=0;j--){
            result[count++]=help[j];
        }
    }
    *returnSize=count;
    return result;
}

两种方法:用字符串 / 不用字符串(Python/Java/C++/Go)

作者 endlesscheng
2023年2月5日 00:16

写法一:用字符串

把 $\textit{nums}[i]$ 转成字符串,即可从高到低遍历数位。

class Solution:
    def separateDigits(self, nums: List[int]) -> List[int]:
        return [int(ch) for x in nums for ch in str(x)]
class Solution {
    public int[] separateDigits(int[] nums) {
        List<Integer> digits = new ArrayList<>();
        for (int x : nums) {
            for (char ch : String.valueOf(x).toCharArray()) {
                digits.add(ch - '0');
            }
        }

        int m = digits.size();
        int[] ans = new int[m];
        for (int i = 0; i < m; i++) {
            ans[i] = digits.get(i);
        }
        return ans;
    }
}
class Solution {
public:
    vector<int> separateDigits(vector<int>& nums) {
        vector<int> ans;
        for (int x : nums) {
            for (char ch : to_string(x)) {
                ans.push_back(ch - '0');
            }
        }
        return ans;
    }
};
func separateDigits(nums []int) (ans []int) {
for _, x := range nums {
for _, ch := range strconv.Itoa(x) {
ans = append(ans, int(ch-'0'))
}
}
return
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n\log U)$,其中 $n$ 是 $\textit{nums}$ 的长度,$U=\max(\textit{nums})$。
  • 空间复杂度:$\mathcal{O}(\log U)$。返回值不计入。

方法二:不用字符串

不断地把 $n$ 除以 $10$(下取整)直到 $0$,例如 $123\to 12\to 1\to 0$。在这个过程中的 $n\bmod 10$,即为每个数位。

这样做是从低到高遍历数位,和题目要求的顺序相反。

我们可以从右到左遍历 $\textit{nums}$,从低到高遍历 $\textit{nums}[i]$ 的数位。最后把遍历过的数位反转,即为答案。

class Solution:
    def separateDigits(self, nums: list[int]) -> list[int]:
        ans = []
        for x in reversed(nums):
            while x > 0:
                ans.append(x % 10)
                x //= 10
        ans.reverse()
        return ans
class Solution {
    public int[] separateDigits(int[] nums) {
        List<Integer> digits = new ArrayList<>();
        for (int i = nums.length - 1; i >= 0; i--) {
            for (int x = nums[i]; x > 0; x /= 10) {
                digits.add(x % 10);
            }
        }

        int m = digits.size();
        int[] ans = new int[m];
        for (int i = 0; i < m; i++) {
            ans[i] = digits.get(m - 1 - i);
        }
        return ans;
    }
}
class Solution {
public:
    vector<int> separateDigits(vector<int>& nums) {
        vector<int> ans;
        for (int x : nums | views::reverse) {
            for (; x > 0; x /= 10) {
                ans.push_back(x % 10);
            }
        }
        ranges::reverse(ans);
        return ans;
    }
};
func separateDigits(nums []int) (ans []int) {
for _, x := range slices.Backward(nums) {
for ; x > 0; x /= 10 {
ans = append(ans, x%10)
}
}
slices.Reverse(ans)
return
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n\log U)$,其中 $n$ 是 $\textit{nums}$ 的长度,$U=\max(\textit{nums})$。
  • 空间复杂度:$\mathcal{O}(1)$。返回值不计入。

分类题单

如何科学刷题?

  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站@灵茶山艾府

昨天 — 2026年5月10日技术

每日一题-达到末尾下标所需的最大跳跃次数🟡

2026年5月10日 00:00

给你一个下标从 0 开始、由 n 个整数组成的数组 nums 和一个整数 target

你的初始位置在下标 0 。在一步操作中,你可以从下标 i 跳跃到任意满足下述条件的下标 j

  • 0 <= i < j < n
  • -target <= nums[j] - nums[i] <= target

返回到达下标 n - 1 处所需的 最大跳跃次数

如果无法到达下标 n - 1 ,返回 -1

 

示例 1:

输入:nums = [1,3,6,4,1,2], target = 2
输出:3
解释:要想以最大跳跃次数从下标 0 到下标 n - 1 ,可以按下述跳跃序列执行操作:
- 从下标 0 跳跃到下标 1 。 
- 从下标 1 跳跃到下标 3 。 
- 从下标 3 跳跃到下标 5 。 
可以证明,从 0 到 n - 1 的所有方案中,不存在比 3 步更长的跳跃序列。因此,答案是 3 。 

示例 2:

输入:nums = [1,3,6,4,1,2], target = 3
输出:5
解释:要想以最大跳跃次数从下标 0 到下标 n - 1 ,可以按下述跳跃序列执行操作:
- 从下标 0 跳跃到下标 1 。 
- 从下标 1 跳跃到下标 2 。 
- 从下标 2 跳跃到下标 3 。 
- 从下标 3 跳跃到下标 4 。 
- 从下标 4 跳跃到下标 5 。 
可以证明,从 0 到 n - 1 的所有方案中,不存在比 5 步更长的跳跃序列。因此,答案是 5 。 

示例 3:

输入:nums = [1,3,6,4,1,2], target = 0
输出:-1
解释:可以证明不存在从 0 到 n - 1 的跳跃序列。因此,答案是 -1 。 

 

提示:

  • 2 <= nums.length == n <= 1000
  • -109 <= nums[i] <= 109
  • 0 <= target <= 2 * 109

C++, 动态规划

作者 liu-xiang-3
2023年7月12日 12:57

思路

  1. f[i]表示从0到下标i的最大跳跃次数;
  2. f[0]=0, f[i]=f[j]+1, 可以从j跳过来;
class Solution {
public:
    int maximumJumps(vector<int>& nums, int target) {
        int n = nums.size();
        vector<int> f(n, -1);
        f[0] = 0;
        /* 开始递推 */
        for (int i = 1; i < n; i++) {
            for (int j = 0; j < i; j++) {
                if (f[j] != -1 && abs(nums[i] - nums[j]) <= target) {
                    f[i] = max(f[i], f[j] + 1);
                }
            }
        }
        return f[n - 1];
    }
};
class Solution:
    def maximumJumps(self, nums: List[int], target: int) -> int:
        n = len(nums)
        f = [-1] * n
        f[0] = 0
        for j in range(1, n):
            for i in range(j):
                if f[i] != -1 and abs(nums[i] - nums[j]) <= target:
                    f[j] = max(f[j], f[i] + 1)
        return f[-1]

【什码情况】Java记忆化搜索

作者 smqk
2023年7月9日 15:37

image.png{:width=400}

解题思路

此处撰写解题思路

代码

###java

class Solution {
    // jump[i] 表示从下标 0 开始跳到 nums[i] 所需的最大跳跃次数
    int[] jump;

    public int maximumJumps(int[] nums, int target) {
        this.jump = new int[nums.length];
        Arrays.fill(jump, Integer.MIN_VALUE);
        jump[0] = 0;
        int maximumJumps = dfs(nums, nums.length - 1, target);
        return maximumJumps >= 0 ? maximumJumps : -1;
    }

    // 返回从下标 i 跳到下标 0 所需最大的跳跃次数
    private int dfs(int[] nums, int i, int target) {
        if (jump[i] != Integer.MIN_VALUE) {
            return jump[i];
        }

        int max = Integer.MIN_VALUE;
        for (int j = i - 1; j >= 0; j--) {
            int val = nums[j] - nums[i];
            if (-target <= val && val <= target) {
                int jump = dfs(nums, j, target) + 1;
                max = Math.max(max, jump);
            }
        }

        return jump[i] = max;
    }

}

最后

如果有帮助到你,请给题解点个收藏,让更多的人看到 ~ ("▔□▔)/

两种方法:普通 DP / 值域线段树优化 DP(Python/Java/C++/Go)

作者 endlesscheng
2023年7月9日 12:09

一、寻找子问题

想一想,最后一步发生了什么?

最后一步,我们从某个满足条件的下标 $i$ 跳到了下标 $n-1$。

枚举满足条件的 $i$,问题变成:

  • 从下标 $0$ 到达下标 $i$ 所需的最大跳跃次数。

这是和原问题相似的、规模更小的子问题,可以用递归解决。

注:从右往左思考,主要是方便把递归翻译成从左往右的递推。从左往右思考也是可以的。

二、状态定义与状态转移方程

根据上面的讨论,定义 $\textit{dfs}(j)$ 表示从下标 $0$ 到达下标 $j$ 所需的最大跳跃次数。

枚举满足 $0\le i<j$ 且 $|\textit{nums}[i]-\textit{nums}[j]|\le \textit{target}$ 的下标 $i$,问题变成从下标 $0$ 到达下标 $i$ 所需的最大跳跃次数,再加上从 $i$ 跳到 $j$ 的一次。

取最大值,得

$$
\textit{dfs}(j) = \max_{i} \textit{dfs}(i) + 1
$$

其中 $0\le i<j$ 且 $|\textit{nums}[i]-\textit{nums}[j]|\le \textit{target}$。

递归边界

  • $\textit{dfs}(0)=0$。从 $0$ 到 $0$ 不用跳。
  • 如果没有满足条件的 $i$,那么 $\textit{dfs}(j) = -\infty$。

递归入口:$\textit{dfs}(n-1)$,这是原问题,也是答案。

三、递归搜索 + 保存递归返回值 = 记忆化搜索

考虑到整个递归过程中有大量重复递归调用(递归入参相同)。由于递归函数没有副作用,同样的入参无论计算多少次,算出来的结果都是一样的,因此可以用记忆化搜索来优化:

  • 如果一个状态(递归入参)是第一次遇到,那么可以在返回前,把状态及其结果记到一个 $\textit{memo}$ 数组中。
  • 如果一个状态不是第一次遇到($\textit{memo}$ 中保存的结果不等于 $\textit{memo}$ 的初始值),那么可以直接返回 $\textit{memo}$ 中保存的结果。

Python 用户可以无视上面这段,直接用 @cache 装饰器。

关于记忆化搜索的原理,请看视频讲解 动态规划入门:从记忆化搜索到递推【基础算法精讲 17】,其中包含把记忆化搜索 1:1 翻译成递推的技巧。

class Solution:
    def maximumJumps(self, nums: List[int], target: int) -> int:
        @cache  # 缓存装饰器,避免重复计算 dfs(一行代码实现记忆化)
        def dfs(j: int) -> int:
            if j == 0:  # 起点
                return 0
            res = -inf
            for i in range(j):
                if abs(nums[i] - nums[j]) <= target:  # 可以从 i 跳到 j
                    res = max(res, dfs(i) + 1)
            return res

        ans = dfs(len(nums) - 1)  # 终点
        return -1 if ans < 0 else ans
class Solution {
    public int maximumJumps(int[] nums, int target) {
        int n = nums.length;
        int[] memo = new int[n];
        int ans = dfs(n - 1, nums, target, memo);
        return ans < 0 ? -1 : ans;
    }

    private int dfs(int j, int[] nums, int target, int[] memo) {
        if (j == 0) { // 起点
            return 0;
        }

        if (memo[j] != 0) { // 之前计算过
            return memo[j];
        }

        int res = Integer.MIN_VALUE;
        for (int i = 0; i < j; i++) {
            if (Math.abs(nums[i] - nums[j]) <= target) { // 可以从 i 跳到 j
                res = Math.max(res, dfs(i, nums, target, memo) + 1);
            }
        }
        memo[j] = res; // 记忆化
        return res;
    }
}
class Solution {
public:
    int maximumJumps(vector<int>& nums, int target) {
        int n = nums.size();
        vector<int> memo(n);

        auto dfs = [&](this auto&& dfs, int j) -> int {
            if (j == 0) { // 起点
                return 0;
            }

            int& res = memo[j]; // 注意这里是引用
            if (res) { // 之前计算过
                return res;
            }

            res = INT_MIN;
            for (int i = 0; i < j; i++) {
                if (abs(nums[i] - nums[j]) <= target) { // 可以从 i 跳到 j
                    res = max(res, dfs(i) + 1);
                }
            }
            return res;
        };

        int ans = dfs(n - 1); // 终点
        return ans < 0 ? -1 : ans;
    }
};
func maximumJumps(nums []int, target int) int {
n := len(nums)
memo := make([]int, n)

var dfs func(int) int
dfs = func(j int) int {
if j == 0 { // 起点
return 0
}

p := &memo[j]
if *p != 0 { // 之前计算过
return *p
}

res := math.MinInt
for i, x := range nums[:j] {
if abs(x-nums[j]) <= target { // 可以从 i 跳到 j
res = max(res, dfs(i)+1)
}
}
*p = res // 记忆化
return res
}

ans := dfs(n - 1) // 终点
if ans < 0 {
return -1
}
return ans
}

func abs(x int) int {
if x < 0 {
return -x
}
return x
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n^2)$,其中 $n$ 是 $\textit{nums}$ 的长度。由于每个状态只会计算一次,动态规划的时间复杂度 $=$ 状态个数 $\times$ 单个状态的计算时间。本题状态个数等于 $\mathcal{O}(n)$,单个状态的计算时间为 $\mathcal{O}(n)$,所以总的时间复杂度为 $\mathcal{O}(n^2)$。
  • 空间复杂度:$\mathcal{O}(n)$。保存多少状态,就需要多少空间。

四、1:1 翻译成递推

我们可以去掉递归中的「递」,只保留「归」的部分,即自底向上计算。

具体来说,$f[j]$ 的定义和 $\textit{dfs}(j)$ 的定义是完全一样的,都表示从下标 $0$ 到达下标 $j$ 所需的最大跳跃次数。

相应的递推式(状态转移方程)也和 $\textit{dfs}$ 一样:

$$
f[j] = \max_{i} f[i] + 1
$$

其中 $0\le i<j$ 且 $|\textit{nums}[i]-\textit{nums}[j]|\le \textit{target}$。

如果没有满足条件的 $i$,那么 $f[j] = -\infty$。

初始值 $f[0]=0$,翻译自递归边界 $\textit{dfs}(0)=0$。

答案为 $f[n-1]$,翻译自递归入口 $\textit{dfs}(n-1)$。

class Solution:
    def maximumJumps(self, nums: List[int], target: int) -> int:
        n = len(nums)
        f = [-inf] * n
        f[0] = 0
        for j in range(1, n):
            for i in range(j):
                if abs(nums[i] - nums[j]) <= target:  # 可以从 i 跳到 j
                    f[j] = max(f[j], f[i] + 1)
        return -1 if f[-1] < 0 else f[-1]
class Solution {
    public int maximumJumps(int[] nums, int target) {
        int n = nums.length;
        int[] f = new int[n];
        for (int j = 1; j < n; j++) {
            f[j] = Integer.MIN_VALUE;
            for (int i = 0; i < j; i++) {
                if (Math.abs(nums[i] - nums[j]) <= target) { // 可以从 i 跳到 j
                    f[j] = Math.max(f[j], f[i] + 1);
                }
            }
        }
        return f[n - 1] < 0 ? -1 : f[n - 1];
    }
}
class Solution {
public:
    int maximumJumps(vector<int>& nums, int target) {
        int n = nums.size();
        vector<int> f(n, INT_MIN);
        f[0] = 0;
        for (int j = 1; j < n; j++) {
            for (int i = 0; i < j; i++) {
                if (abs(nums[i] - nums[j]) <= target) { // 可以从 i 跳到 j
                    f[j] = max(f[j], f[i] + 1);
                }
            }
        }
        return f[n - 1] < 0 ? -1 : f[n - 1];
    }
};
func maximumJumps(nums []int, target int) int {
n := len(nums)
f := make([]int, n)

for j := 1; j < n; j++ {
f[j] = math.MinInt
for i, x := range nums[:j] {
if abs(x-nums[j]) <= target { // 可以从 i 跳到 j
f[j] = max(f[j], f[i]+1)
}
}
}

if f[n-1] < 0 {
return -1
}
return f[n-1]
}

func abs(x int) int {
if x < 0 {
return -x
}
return x
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n^2)$,其中 $n$ 是 $\textit{nums}$ 的长度。
  • 空间复杂度:$\mathcal{O}(n)$。

五、值域线段树优化 DP

如果 $n=10^5$,上面的做法就超时了。

遍历到 $\textit{nums}[j]$ 时,我们需要知道满足 $\textit{nums}[j]-\textit{target} \le \textit{nums}[i] \le \textit{nums}[j]+\textit{target}$ 的最大的 $f[i]$。

这可以用一棵值域线段树维护。线段树的区间是值域区间,例如区间 $[20,23]$ 指的是 $\textit{nums}$ 中的元素 $20,21,22,23$。线段树的每个节点保存的是值域区间对应的最大的 $f[i]$。例如 $\textit{nums}[4]=20$ 且 $f[4] = 3$,那么线段树维护的位置 $20$ 更新为 $3$。

如此一来,满足 $\textit{nums}[j]-\textit{target} \le \textit{nums}[i] \le \textit{nums}[j]+\textit{target}$ 的最大的 $f[i]$,可以通过线段树的区间最值查询得到。

# 完整的线段树模板见 https://leetcode.cn/circle/discuss/mOr1u6/
class SegmentTree:
    def __init__(self, n: int) -> None:
        self.t = [-inf] * (2 << (n - 1).bit_length())

    def update(self, node: int, l: int, r: int, i: int, val: int) -> None:
        if l == r:  # 叶子
            self.t[node] = val
            return
        m = (l + r) // 2
        if i <= m:  # i 在左子树
            self.update(node * 2, l, m, i, val)
        else:  # i 在右子树
            self.update(node * 2 + 1, m + 1, r, i, val)
        self.t[node] = max(self.t[node * 2], self.t[node * 2 + 1])

    def query(self, node: int, l: int, r: int, ql: int, qr: int) -> int:
        if ql <= l and r <= qr:  # 当前子树完全在 [ql, qr] 内
            return self.t[node]
        m = (l + r) // 2
        if qr <= m:  # [ql, qr] 在左子树
            return self.query(node * 2, l, m, ql, qr)
        if ql > m:  # [ql, qr] 在右子树
            return self.query(node * 2 + 1, m + 1, r, ql, qr)
        return max(self.query(node * 2, l, m, ql, qr), self.query(node * 2 + 1, m + 1, r, ql, qr))


class Solution:
    def maximumJumps(self, nums: List[int], target: int) -> int:
        # 去重排序,便于离散化
        sorted_nums = sorted(set(nums))

        n = len(nums)
        m = len(sorted_nums)
        t = SegmentTree(m)  # 值域线段树

        # nums[0] 对应的 f[0] = 0
        t.update(1, 0, m - 1, bisect_left(sorted_nums, nums[0]), 0)

        for j in range(1, n):
            l = bisect_left(sorted_nums, nums[j] - target)       # >= nums[j]-target 的第一个数
            r = bisect_right(sorted_nums, nums[j] + target) - 1  # <= nums[j]+target 的最后一个数
            # t.query 返回满足 nums[j]-target <= nums[i] <= nums[j]+target 的最大的 f[i]
            fj = t.query(1, 0, m - 1, l, r) + 1
            t.update(1, 0, m - 1, bisect_left(sorted_nums, nums[j]), fj)

        return -1 if fj < 0 else fj
class Solution {
    // 完整的线段树模板见 https://leetcode.cn/circle/discuss/mOr1u6/
    private int[] tree;

    private void update(int node, int l, int r, int i, int val) {
        if (l == r) { // 叶子
            tree[node] = val;
            return;
        }
        int m = (l + r) / 2;
        if (i <= m) { // i 在左子树
            update(node * 2, l, m, i, val);
        } else { // i 在右子树
            update(node * 2 + 1, m + 1, r, i, val);
        }
        tree[node] = Math.max(tree[node * 2], tree[node * 2 + 1]);
    }

    private int query(int node, int l, int r, int ql, int qr) {
        if (ql <= l && r <= qr) { // 当前子树完全在 [ql, qr] 内
            return tree[node];
        }
        int m = (l + r) / 2;
        if (qr <= m) { // [ql, qr] 在左子树
            return query(node * 2, l, m, ql, qr);
        }
        if (ql > m) { // [ql, qr] 在右子树
            return query(node * 2 + 1, m + 1, r, ql, qr);
        }
        return Math.max(query(node * 2, l, m, ql, qr), query(node * 2 + 1, m + 1, r, ql, qr));
    }

    public int maximumJumps(int[] nums, int target) {
        int n = nums.length;
        int[] sorted = nums.clone(); // 用于离散化
        Arrays.sort(sorted);

        tree = new int[2 << (32 - Integer.numberOfLeadingZeros(n - 1))];
        Arrays.fill(tree, Integer.MIN_VALUE);

        // nums[0] 对应的 f[0] = 0
        update(1, 0, n - 1, lowerBound(sorted, nums[0]), 0);

        for (int j = 1; ; j++) {
            int l = lowerBound(sorted, (long) nums[j] - target);         // >= nums[j]-target 的第一个数
            int r = lowerBound(sorted, (long) nums[j] + target + 1) - 1; // <= nums[j]+target 的最后一个数
            // query 返回满足 nums[j]-target <= nums[i] <= nums[j]+target 的最大的 f[i]
            int fj = query(1, 0, n - 1, l, r) + 1;
            if (j == n - 1) {
                return fj < 0 ? -1 : fj;
            }
            update(1, 0, n - 1, lowerBound(sorted, nums[j]), fj);
        }
    }

    // 见 https://www.bilibili.com/video/BV1AP41137w7/
    private int lowerBound(int[] nums, long target) {
        int left = -1;
        int right = nums.length;
        while (left + 1 < right) {
            int mid = (left + right) >>> 1;
            if (nums[mid] >= target) {
                right = mid;
            } else {
                left = mid;
            }
        }
        return right;
    }
}
// 完整的线段树模板见 https://leetcode.cn/circle/discuss/mOr1u6/
class SegmentTree {
    vector<int> tree;

public:
    SegmentTree(int n) : tree(2 << bit_width(n - 1u), INT_MIN) {}

    void update(int node, int l, int r, int i, int val) {
        if (l == r) { // 叶子
            tree[node] = val;
            return;
        }
        int m = (l + r) / 2;
        if (i <= m) { // i 在左子树
            update(node * 2, l, m, i, val);
        } else { // i 在右子树
            update(node * 2 + 1, m + 1, r, i, val);
        }
        tree[node] = max(tree[node * 2], tree[node * 2 + 1]);
    }

    int query(int node, int l, int r, int ql, int qr) const {
        if (ql <= l && r <= qr) { // 当前子树完全在 [ql, qr] 内
            return tree[node];
        }
        int m = (l + r) / 2;
        if (qr <= m) { // [ql, qr] 在左子树
            return query(node * 2, l, m, ql, qr);
        }
        if (ql > m) { // [ql, qr] 在右子树
            return query(node * 2 + 1, m + 1, r, ql, qr);
        }
        return max(query(node * 2, l, m, ql, qr), query(node * 2 + 1, m + 1, r, ql, qr));
    }
};

class Solution {
public:
    int maximumJumps(vector<int>& nums, int target) {
        // 排序去重,便于离散化
        auto sorted = nums;
        ranges::sort(sorted);
        sorted.erase(ranges::unique(sorted).begin(), sorted.end());

        int n = nums.size();
        int m = sorted.size();

        SegmentTree t(m); // 值域线段树

        // nums[0] 对应的 f[0] = 0
        int pos = ranges::lower_bound(sorted, nums[0]) - sorted.begin();
        t.update(1, 0, m - 1, pos, 0);

        long long tar = target;
        for (int j = 1; ; j++) {
            int l = ranges::lower_bound(sorted, nums[j] - tar) - sorted.begin();     // >= nums[j]-target 的第一个数
            int r = ranges::upper_bound(sorted, nums[j] + tar) - sorted.begin() - 1; // <= nums[j]+target 的最后一个数
            // t.query 返回满足 nums[j]-target <= nums[i] <= nums[j]+target 的最大的 f[i]
            int fj = t.query(1, 0, m - 1, l, r) + 1;
            if (j == n - 1) {
                return fj < 0 ? -1 : fj;
            }
            pos = ranges::lower_bound(sorted, nums[j]) - sorted.begin();
            t.update(1, 0, m - 1, pos, fj);
        }
    }
};
// 完整的线段树模板见 https://leetcode.cn/circle/discuss/mOr1u6/
type seg []int

func (t seg) update(node, l, r, i, val int) {
if l == r { // 叶子
t[node] = val
return
}
m := (l + r) / 2
if i <= m { // i 在左子树
t.update(node*2, l, m, i, val)
} else { // i 在右子树
t.update(node*2+1, m+1, r, i, val)
}
t[node] = max(t[node*2], t[node*2+1])
}

func (t seg) query(node, l, r, ql, qr int) int {
if ql <= l && r <= qr { // 当前子树完全在 [ql, qr] 内
return t[node]
}
m := (l + r) / 2
if qr <= m { // [ql, qr] 在左子树
return t.query(node*2, l, m, ql, qr)
}
if ql > m { // [ql, qr] 在右子树
return t.query(node*2+1, m+1, r, ql, qr)
}
return max(t.query(node*2, l, m, ql, qr), t.query(node*2+1, m+1, r, ql, qr))
}

func maximumJumps(nums []int, target int) int {
// 排序去重,便于离散化
sorted := slices.Clone(nums)
slices.Sort(sorted)
sorted = slices.Compact(sorted)

n := len(nums)
m := len(sorted)

t := make(seg, 2<<bits.Len(uint(m-1))) // 值域线段树
for i := range t {
t[i] = math.MinInt
}

// nums[0] 对应的 f[0] = 0
t.update(1, 0, m-1, sort.SearchInts(sorted, nums[0]), 0)

for j := 1; ; j++ {
l := sort.SearchInts(sorted, nums[j]-target)       // >= nums[j]-target 的第一个数
r := sort.SearchInts(sorted, nums[j]+target+1) - 1 // <= nums[j]+target 的最后一个数
// t.query 返回满足 nums[j]-target <= nums[i] <= nums[j]+target 的最大的 f[i]
fj := t.query(1, 0, m-1, l, r) + 1
if j == n-1 {
if fj < 0 {
return -1
}
return fj
}
t.update(1, 0, m-1, sort.SearchInts(sorted, nums[j]), fj)
}
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n\log n)$,其中 $n$ 是 $\textit{nums}$ 的长度。
  • 空间复杂度:$\mathcal{O}(n)$。

相似题目

2407. 最长递增子序列 II

更多相似题目,见下面动态规划题单的「§11.4 树状数组/线段树优化 DP」和「专题:跳跃游戏」。

分类题单

如何科学刷题?

  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站@灵茶山艾府

昨天以前技术

把一份前端 checklist 变成 AI 的 Skill:让 CR 不再靠记忆

作者 jump_jump
2026年5月9日 23:17

引子:一个吃灰三年的项目被重新盘活

写这篇博客的由头有点特别。

我有一个叫 front-end-checklist 的老项目(网页:wsafight.github.io/front-end-c…),2023 年初在公司做 Code Review 的时候顺手整理出来的。那会儿评审新同学的代码,总是在重复同样的话:"这里没做 XSS 转义"、"useEffect 里有竞态"、"label 没和 input 关联"。后来干脆把这些反复出现的问题写成清单,用 Jekyll 挂在 GitHub Pages 上。

然后它就一直在那儿积灰。2024 只有一次提交,2025 只有一次,到了 2026 年 5 月也还没动过。

2026-05-08 晚上,我本来只想顺手改一下清单里过时的条目,结果一头扎进 Claude Code,两小时做了 22 次提交,把这个静态页面彻底改造了一遍。

真正让我想写这篇文章的,不是"AI 让我写代码变快"。快是肯定快,但这次改造最后落到了一件我之前没想过的事情上——一份静态文档,换了个交付形式,才真正开始被用起来。

清单本身:三年沉淀下来的 160 条检查项

先交代清楚这个清单是什么。

它不是 ESLint 能查的那种风格规则,而是 Code Review 时需要结合业务和上下文判断的问题。目前一共 25 个分组、160 条检查项:

命名规范      数据与类型    函数设计      状态管理      控制流
异步处理      数据请求      UI 与渲染     样式与响应式  路由与权限
性能          安全与健壮性  表单与交互    错误处理      测试
无障碍访问    用户体验      代码质量      工程化        国际化
日志与监控    依赖管理      浏览器兼容    文档与协作    PR 自检

随便挑几条看看:

  • 区分"缺失"、"为空"、"为 0"、"为空数组"、"为空字符串"的业务含义
  • 处理并发请求的竞态问题,避免旧响应覆盖新状态
  • 同一表单/输入控件不要在受控与非受控之间切换
  • useEffect / watch 的依赖项必须完整,避免闭包捕获过期值
  • 定时器和事件监听记得清除,否则可能引发内存泄漏
  • 表单错误应定位到具体字段,而不是只给出笼统提示

这些不是靠格式化、类型推导或者简单 AST 规则就能稳定抓出来的问题。它们往往来自真实线上事故、返工、误解和交接成本。以前这些经验只能靠人去记,记不住就会在别的项目里重新踩一次。

痛点:清单挂在网上,但不会进入工作流

清单做完这三年,我一直有个遗憾:没人真的会去翻

我自己都不翻。做一个 PR 的时候,我知道清单里有条"处理并发请求的竞态",但我会不会每次都老老实实打开页面对一遍?不会。同事更不会。新人入职时我会把链接丢给他们,他们收藏,然后就再也不打开了。

这是很多静态文档共同的问题:信息在那里,但你必须主动去触达它。而在 CR 场景里,主动触达的前提是你已经意识到自己可能漏了什么。可真正漏掉的时候,人往往意识不到。

我试过一些办法:

  • 写成 Markdown 放 README:没人会在写代码时切到 README 里逐条对照。
  • 做成可搜索的网页:搜索的前提是你已经知道关键词,可 XSS、竞态、状态错位这类问题,经常就是因为你没意识到该搜什么。
  • 加上勾选和进度追踪:这次改造时我反而把它们删了,因为它把"查阅文档"变成了"填表",使用意愿更低。

根本问题不是文档形式不够花哨,而是查阅式文档很难主动进入人的工作流。

转折:把清单变成 AI 的上下文

Claude Code 的 Skill 机制改变了这件事。

简单说,Skill 是一段给 AI 读的说明书,加上它执行任务时需要参考的资料。当你在对话里说到特定触发词时,AI 会加载这个 Skill,并按说明书走流程。

我的 frontend-checklist Skill 里写的是这样的逻辑:

  1. 只在用户明确请求时触发:比如 /frontend-checklist、"按前端清单 review"、"按 checklist 检查这段代码"。用户没提,它不会自作主张去扫代码。
  2. 确认检查范围:如果用户指定了文件,就只看指定文件;如果说 "review PR",就用 git diff 定位变更;如果都没有,就先确认范围,不盲目扫整个仓库。
  3. 按语言选择清单:中文提问读中文版清单,英文提问读英文版清单。
  4. 只报命中的问题:通过和不适用的条目一律不输出。
  5. 按严重度排序:安全、数据丢失、竞态、内存泄漏这类硬问题优先,命名风格靠后。
  6. 每个问题都标出文件路径和行号:让人能直接跳到具体位置。

关键的翻转在这里:

以前是"你去翻清单"。现在是"清单来找你"。

写 PR 的时候,你不需要记得清单里每一条是什么,直接在 IDE 里说一句"按前端清单 review 我这个 PR",AI 会把 160 条和当前 diff 放在一起看,只告诉你命中的那些,并给出文件路径和行号,必要时附 1-3 行示例代码。心智负担从"记住 160 条"降到"知道有这么一个入口"。

一个真实跑出来的 review

空讲没意思,看一段 showcase 里的实际输出。

下面这段代码是我写的一个有意设计的"坏例子"(showcase/cases/01-xss/bad.tsx),一个评论列表组件:

export function CommentList(props: any) {
  const [list, setList] = useState([] as any);
  const [keyword, setKeyword] = useState('');

  useEffect(() => {
    fetch('/api/comments?topic=' + props.topic)
      .then((r) => r.json())
      .then((d) => {
        setList(d.data);
      });
  }, [props.topic]);

  const highlight = (text, kw) => {
    if (!kw) return text;
    return text.replace(new RegExp(kw, 'g'), '<mark>' + kw + '</mark>');
  };

  return (
    <div>
      <input type="text" onChange={onSearch} />
      <div id="tip" dangerouslySetInnerHTML={{ __html: props.tip }} />
      {list.map((c: Comment, i: number) => (
        <div key={i} className="comment">
          <img src={c.avatar} />
          <a href={'javascript:void(0)'} onClick={() => eval(c.author.onClick)}>
            {c.author.name}
          </a>
          <div dangerouslySetInnerHTML={{ __html: highlight(c.body, keyword) }} />
        </div>
      ))}
    </div>
  );
}

乍看能跑,TypeScript 不一定报错,ESLint 也不一定能拦住关键问题。但 Skill 跑完吐出来 11 条命中,挑几条看(行号对应 showcase/cases/01-xss/bad.tsx 原文件,不是上面代码块里的相对行号):

安全与健壮性(最严重的一批)

  • line 35dangerouslySetInnerHTML={{ __html: props.tip }} 直接吃外部 tip,存在 XSS 风险。建议默认文本渲染,真要富文本时先用 DOMPurify 消毒。
  • line 44highlight 用字符串拼接 HTML 再 dangerouslySetInnerHTMLbodykeyword 都没转义。建议改成 String.split 分段渲染,把命中段包进 <mark>,不要注入 HTML。
  • line 39eval(c.author.onClick) 把接口返回的字符串当代码执行,等于把任意脚本执行权交给接口数据。建议彻底删除,交互改成前端静态映射。

数据请求

  • line 14-20props.topic 变化时发起新请求但没取消旧请求,旧响应可能覆盖新数据。建议用 AbortControllerignore 标志在 cleanup 里关掉。

UI 与渲染

  • line 37key={i} 用数组下标,列表增删重排时可能导致状态错位。建议用稳定的业务 id,比如 c.id

无障碍

  • line 34, 38:搜索 input 没有 aria-label 或关联 label<img> 没有 alt

泛泛地让 AI 做 review,它很容易给出"结构清晰、建议补充错误处理"这类通用意见。扔给 ESLint,也大概率只会在 any、未定义变量、hook 依赖这类规则上发声。Skill 的价值在于把评审标准显式化:AI 不是凭感觉聊几句,而是按一把真实的尺子在量代码。

我还拿其他 showcase 跑了一遍:用户资料页命中 12 条(竞态、未清理副作用、无空值守卫),注册表单命中 17 条(a11y、字段级错误、密码明文 input、防重复提交)。这些都是一眼看过去"差不多能用",但上线后很容易变成坑的代码。

怎么用

Skill 装起来一条命令的事:

curl -fsSL https://github.com/wsafight/front-end-checklist/releases/latest/download/install.sh \
  | sh -s -- claude

装完在对话里说 /frontend-checklist 或"按前端清单 review 我这个 PR",就会触发它。也支持 Kiro / Cursor / Codex,把命令结尾换一下就行。

清单是中英双语的,AI 会根据你提问的语言自动选对应版本。


两小时改造一个老项目听起来像标题党,但实际发生的事情比这更有意思:一份躺了三年没人翻的清单,换了个交付形式,突然就活过来了。内容还是那 160 条,变的只是"它怎么到达读者"。

如果你手里也有这种"明明有价值但没人用"的老文档,值得花个晚上,把它接进 AI 看看。

— 2026-05-09 夜

你还在手动敲命令部署?GitHub Actions 让你 push 即上线,摸鱼时间翻倍

作者 kyriewen
2026年5月9日 23:08

你改完代码,打开终端,输入 npm run build,然后 FTP 上传,或者登录服务器 git pull。这一套操作每天重复 N 次,不累吗?今天我们来把“部署”这件事自动化——用 GitHub Actions,只要你 git push,代码自动测试、自动打包、自动发到服务器。以后你只管写代码,上线交给机器人。

前言

我见过太多团队还停留在“手工部署”时代:上线先发个群消息“我要部署了,大家别动”,然后手动打包、上传、解压、重启。万一忘了执行某个步骤,线上就挂了。

GitHub Actions 就是你的免费 DevOps 机器人。它能监听 GitHub 上的事件(push、pull request、issue),然后执行你写好的自动化脚本。我们只需要写一个 YAML 文件,放在 .github/workflows 目录下,剩下的全部自动。

今天我们就来写一个完整的工作流:当推送到 main 分支时,自动运行测试、构建、并部署到服务器(或 Vercel / 阿里云 OSS)。全程保姆级,复制粘贴就能用。

一、准备工作:你需要什么?

  • GitHub 仓库(私有或公开都可以)。
  • 一台服务器(或云存储,如阿里云 OSS、Vercel)。
  • 如果部署到自己的服务器,需要服务器的 SSH 密钥(免密登录)。

如果你没有服务器,可以用 Vercel(个人项目免费,连 GitHub 自动部署也是免费的,甚至不需要写 Actions——但为了教学,我们还是会演示自定义部署到服务器的流程)。

二、基础工作流:跑测试 + 打包

在项目根目录创建 .github/workflows/deploy.yml

name: CI/CD Pipeline

on:
  push:
    branches: [ main ]   # 当推送到 main 分支时触发
  pull_request:
    branches: [ main ]   # PR 时也跑测试,但不部署

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: 检出代码
        uses: actions/checkout@v4

      - name: 安装 Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 18

      - name: 安装依赖
        run: npm ci

      - name: 运行测试
        run: npm test

  build:
    runs-on: ubuntu-latest
    needs: test   # 测试通过后才构建
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 18
      - run: npm ci
      - run: npm run build
      - name: 上传构建产物(给后续部署用)
        uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/

提交这个文件后,每次 git push main,GitHub 就会自动跑测试和构建。你可以在仓库的 Actions 标签页看到实时日志。

三、部署到自己的服务器(通过 SSH)

deploy.yml 中增加一个 job,依赖 build,然后通过 SSH 把构建产物上传到服务器。

首先在 GitHub 仓库 Settings → Secrets and variables → Actions 中添加几个密钥:

  • SERVER_HOST:你的服务器 IP
  • SERVER_USERNAME:登录用户名(如 root、ubuntu)
  • SSH_PRIVATE_KEY:服务器的私钥内容(复制 ~/.ssh/id_rsa 整个内容)

然后在 deploy.yml 中添加:

  deploy:
    runs-on: ubuntu-latest
    needs: build
    if: github.event_name == 'push'   # 只有 push 时部署,PR 不部署
    steps:
      - name: 下载构建产物
        uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist

      - name: 通过 SSH 部署
        uses: easingthemes/ssh-deploy@main
        env:
          SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
          ARGS: "-rlgoDzvc -i --delete"
          SOURCE: "dist/"
          REMOTE_HOST: ${{ secrets.SERVER_HOST }}
          REMOTE_USER: ${{ secrets.SERVER_USERNAME }}
          TARGET: "/var/www/myapp/"   # 服务器上的目标目录

这样,每次 git push main,代码会自动出现在 /var/www/myapp 中。如果服务器上跑着 Nginx,刷新页面就是新版。

如果想要重启 PM2 进程,可以在部署步骤后加一个 exec 命令:

      - name: 重启 PM2 服务(如果后端是 Node)
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USERNAME }}
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd /var/www/myapp
            pm2 restart myapp

四、部署到 Vercel(更简单)

如果你的项目是前端静态站点,Vercel 本身就是和 GitHub 集成的。但你也可以手动写 Actions 来调用 Vercel CLI。不过更推荐直接在 Vercel 网站导入 GitHub 仓库,它会自动监听 main 分支并部署,连 YAML 都不用写。

如果你坚持要用 Actions 调用 Vercel:

      - name: 部署到 Vercel
        uses: amondnet/vercel-action@v20
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.ORG_ID}}
          vercel-project-id: ${{ secrets.PROJECT_ID}}
          vercel-args: '--prod'

五、部署到阿里云 OSS(静态网站托管)

阿里云 OSS 支持静态网站。我们可以用 aliyun-cli 同步文件:

      - name: 安装阿里云 CLI
        run: npm install -g @alicloud/oss

      - name: 同步到 OSS
        run: |
          oss cp dist/ oss://my-bucket/ -r --force --access-key-id ${{ secrets.OSS_KEY_ID }} --access-key-secret ${{ secrets.OSS_KEY_SECRET }} --endpoint oss-cn-hangzhou.aliyuncs.com

六、进阶:分环境部署(dev/staging/prod)

你可以通过分支名来区分环境:

  • main 分支 → 生产环境
  • develop 分支 → 测试环境

on.push.branches 里可以写多个分支,然后在 job 里根据 github.ref_name 做判断,选择不同的服务器目录或环境变量。

七、常见坑点

  • 密钥泄露:永远不要把 SSH 私钥、密码明文写在代码里,要用 GitHub Secrets。
  • 构建产物太大upload-artifact 可能较慢,对于几 MB 的项目还好,几百 MB 建议直接推送到服务器或云存储。
  • 权限问题:确保服务器上目标目录有写入权限。
  • 缓存依赖:可以加 actions/cache 来缓存 node_modules,每次 build 快很多。

八、总结:让机器人替你干活

  • 写好 .github/workflows/deploy.yml,push 即触发。
  • 用 Secrets 存放敏感信息。
  • 可以串联测试、构建、部署,还能加个钉钉/飞书通知。

从此你只需要 git add . && git commit -m "fix: xxx" && git push,然后去倒杯水。回来群里就会收到:“生产环境部署成功,版本 v1.2.3”。不用记命令、不用等上传、不怕忘步骤。这才是现代前端该有的体验。

如果你觉得今天的“自动化”够解放双手,点个赞让更多人看到。评论区聊聊:你经历过哪些手工部署的惨案?

Pinia 状态管理

作者 Csvn
2026年5月9日 21:38

引言

在 Vue 3 项目中,状态管理是不可或缺的一部分。随着 Vuex 逐渐被 Pinia 取代,Pinia 凭借其更简洁的 API、更好的 TypeScript 支持和更轻量的体积,成为了 Vue 生态中的首选状态管理方案。本文将深入讲解 Pinia 的核心概念和实战技巧。

什么是 Pinia?

Pinia 是 Vue 官方推荐的状态管理库,可以看作是 Vuex 的继任者。它具有以下特点:

  • 轻量级:仅 1KB 大小(压缩后)
  • TypeScript 友好:完整的类型推断
  • 简洁 API:基于 Composition API 的设计
  • 模块化:天然支持代码分割
  • DevTools 支持:集成 Vue DevTools

核心概念

Store

Store 是 Pinia 的核心,用于存储和管理应用状态。每个 Store 都是独立的,可以单独使用。

State

State 是响应式的数据源,类似于 Vue 组件中的 data

Getters

Getters 用于计算派生状态,类似于 Vue 的 computed

Actions

Actions 用于处理业务逻辑,可以包含异步操作,类似于 Vue 组件中的 methods

快速上手

安装

npm install pinia

创建 Store

使用 defineStore 创建 Store:

// stores/counter.js
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  // State
  state: () => ({
    count: 0,
    name: 'Pinia'
  }),
  
  // Getters
  getters: {
    doubleCount: (state) => state.count * 2,
    fullName: (state) => `Hello, ${state.name}!`
  },
  
  // Actions
  actions: {
    increment() {
      this.count++
    },
    decrement() {
      this.count--
    },
    setCount(value) {
      this.count = value
    },
    async fetchCount() {
      const response = await fetch('/api/count')
      const data = await response.json()
      this.count = data.count
    }
  }
})

在组件中使用

<template>
  <div>
    <h1>{{ counterStore.fullName }}</h1>
    <p>Count: {{ counterStore.count }}</p>
    <p>Double: {{ counterStore.doubleCount }}</p>
    <button @click="counterStore.increment">+</button>
    <button @click="counterStore.decrement">-</button>
  </div>
</template>

<script setup>
import { useCounterStore } from '@/stores/counter'

const counterStore = useCounterStore()
</script>

高级用法

Store 持久化

使用 pinia-plugin-persistedstate 实现状态持久化:

npm install pinia-plugin-persistedstate
// main.js
import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'

const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)

app.use(pinia)
// stores/user.js
export const useUserStore = defineStore('user', {
  state: () => ({
    token: '',
    userInfo: null
  }),
  actions: {
    login(token, userInfo) {
      this.token = token
      this.userInfo = userInfo
    }
  },
  persist: true // 自动持久化到 localStorage
})

多个 Store 协作

// stores/cart.js
import { defineStore } from 'pinia'
import { useProductStore } from './product'

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: []
  }),
  actions: {
    addToProduct(productId, quantity) {
      const productStore = useProductStore()
      const product = productStore.getProductById(productId)
      
      const existingItem = this.items.find(item => item.id === productId)
      if (existingItem) {
        existingItem.quantity += quantity
      } else {
        this.items.push({
          id: productId,
          name: product.name,
          price: product.price,
          quantity
        })
      }
    },
    
    get totalPrice() {
      return this.items.reduce((sum, item) => {
        return sum + item.price * item.quantity
      }, 0)
    }
  }
})

组合 Store

// stores/composed.js
import { defineStore } from 'pinia'
import { useUserStore } from './user'
import { useCartStore } from './cart'

export const useCheckoutStore = defineStore('checkout', () => {
  const userStore = useUserStore()
  const cartStore = useCartStore()
  
  const canCheckout = computed(() => {
    return userStore.isLoggedIn && cartStore.items.length > 0
  })
  
  const checkout = async () => {
    if (!canCheckout.value) {
      throw new Error('Cannot checkout')
    }
    
    // 处理结账逻辑
    const result = await fetch('/api/checkout', {
      method: 'POST',
      body: JSON.stringify({
        userId: userStore.userInfo.id,
        items: cartStore.items
      })
    })
    
    return result.json()
  }
  
  return { canCheckout, checkout }
})

最佳实践

1. 合理的 Store 划分

按业务模块划分 Store,而不是按功能类型:

stores/
├── user.js      # 用户相关
├── product.js   # 商品相关
├── cart.js      # 购物车
└── order.js     # 订单

2. 使用 TypeScript

interface User {
  id: number
  name: string
  email: string
}

export const useUserStore = defineStore('user', {
  state: (): { user: User | null } => ({
    user: null
  }),
  actions: {
    setUser(user: User) {
      this.user = user
    }
  }
})

3. 避免直接修改 State

始终通过 Actions 修改状态:

// ❌ 不推荐
store.count = 100

// ✅ 推荐
store.setCount(100)

4. 使用 Getters 进行派生计算

// ✅ 推荐
getters: {
  activeUsers: (state) => state.users.filter(user => user.isActive)
}

// ❌ 不推荐
computed: {
  activeUsers() {
    return this.users.filter(user => user.isActive)
  }
}

总结

Pinia 以其简洁的 API、优秀的 TypeScript 支持和轻量的体积,成为了 Vue 3 项目状态管理的首选。通过合理的 Store 划分、类型安全和最佳实践,可以构建出可维护、可扩展的状态管理方案。

关键要点:

  • 使用 defineStore 创建 Store
  • State、Getters、Actions 三大核心
  • 支持持久化和多 Store 协作
  • 推荐按业务模块划分 Store
  • 充分利用 TypeScript 类型推断

Pinia 让 Vue 应用的状态管理变得更加简单和优雅!

Claude Code + Amazon Bedrock 使用指南

作者 一棵白菜
2026年5月9日 20:34

Claude Code + Amazon Bedrock 使用指南

一、Claude Code 是什么?

Claude Code 是 Anthropic 官方推出的 AI 编程 CLI 工具,可以:

  • 直接在终端中与 AI 对话,完成代码编写、调试、重构
  • 自动读取项目代码上下文,理解整个代码库
  • 执行 shell 命令、编辑文件、运行测试
  • 支持 VS Code / Cursor 等 IDE 集成

通过 Amazon Bedrock 接入,我们无需使用个人 Anthropic API Key,统一走公司 AWS 账号,费用由公司统一结算。

二、前置条件

在开始之前,请确认以下环境已准备就绪:

条件 要求 检查方式
Node.js >= 18.0 node --version
AWS CLI v2 aws --version
AWS 凭证 已配置 aws sts get-caller-identity
操作系统 macOS / Linux / Windows (WSL) -

2.1 安装 Node.js

如果尚未安装 Node.js,推荐使用 nvm

# macOS / Linux
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
source ~/.zshrc  # 或 source ~/.bashrc
nvm install 22
nvm use 22

2.2 安装并配置 AWS CLI

# macOS
brew install awscli

# 验证安装
aws --version

2.3 配置 AWS 凭证

请联系 DevOps 团队获取你的 AWS Access Key,然后执行:

aws configure

按提示输入:

AWS Access Key ID: <你的 Access Key ID>
AWS Secret Access Key: <你的 Secret Access Key>
Default region name: us-east-1
Default output format: json

验证凭证是否生效:

aws sts get-caller-identity

如果返回了你的 Account ID 和 ARN,说明配置成功。

三、安装 Claude Code

# 全局安装
npm install -g @anthropic-ai/claude-code

# 验证安装
claude --version

如遇权限问题(macOS),可尝试:

sudo npm install -g @anthropic-ai/claude-code --unsafe-perm

四、配置 Bedrock 连接

4.1 创建配置文件

编辑(或创建)Claude Code 的配置文件 ~/.claude/settings.json

{
  "env": {
    "CLAUDE_CODE_USE_BEDROCK": "1",
    "ANTHROPIC_MODEL": "us.anthropic.claude-sonnet-4-6-v1",
    "AWS_REGION": "us-east-1",
    "AWS_PROFILE": "default",
    "DISABLE_AUTOUPDATE": "1"
  },
  "permissions": {
    "allow": [],
    "deny": []
  }
}

配置文件路径:

操作系统 路径
macOS / Linux ~/.claude/settings.json
Windows %USERPROFILE%.claude\settings.json

注意:如果文件已存在其他配置,请将 env 字段合并进去,不要覆盖已有内容。

4.2 跳过登录引导流程

Claude Code 首次启动会进入 Anthropic 的登录引导(Onboarding)流程。使用 Bedrock 时需要跳过此步骤。

创建(或编辑)文件 ~/.claude.json(注意:是用户主目录下的 .claude.json,不是 .claude/ 目录里的):

{
  "hasCompletedOnboarding": true
}

说明:设置 hasCompletedOnboardingtrue 后,Claude Code 启动时会跳过默认的 Anthropic OAuth 登录流程,直接使用 settings.json 中配置的 Bedrock 连接。

4.3 验证配置

修改完成后重新打开终端,然后执行:

claude --version

如果没有弹出登录提示,说明配置成功。

五、可用模型

公司 Bedrock 账号已开通以下 Claude 模型:

模型 Bedrock Model ID 适用场景 相对成本
Claude Sonnet 4.6 us.anthropic.claude-sonnet-4-6-v1 日常编码、代码审查、调试(推荐默认)
Claude Haiku 4.5 us.anthropic.claude-haiku-4-5-v1 快速问答、简单任务、节省成本 ★(最低)
Claude Opus 4.6 us.anthropic.claude-opus-4-6-v1 复杂架构设计、深度推理 ★★★(最高)

切换模型

# 方式一:启动时指定
claude --model us.anthropic.claude-opus-4-6-v1

# 方式二:对话中切换
# 输入 /model 命令选择模型

成本提醒:Opus 模型费用约为 Sonnet 的 5 倍,请根据任务复杂度合理选择。日常开发建议使用 Sonnet,简单查询使用 Haiku。

六、快速上手

6.1 启动 Claude Code

# 在项目根目录启动
cd your-project
claude

首次启动会显示欢迎信息,确认模型连接成功。

6.2 基本用法

# 让 AI 解释代码
> 解释一下 src/main/java/com/example/UserService.java 的主要逻辑

# 让 AI 写代码
> 帮我写一个用户注册的 REST API,使用 Spring Boot

# 让 AI 修 Bug
> 这个 NullPointerException 是什么原因?帮我修复

# 让 AI 重构
> 把这个方法拆分成更小的函数,遵循单一职责原则

# 运行命令
> 运行项目的单元测试并分析失败原因

6.3 IDE 集成

Claude Code 同时支持在 IDE 中使用:

VS Code:

  1. 安装扩展:搜索 "Claude Code" 并安装
  2. 打开命令面板(Cmd+Shift+P),输入 "Claude"
  3. 快捷键 Cmd+Esc 打开 Claude 面板

JetBrains(IntelliJ IDEA 等):

  1. Settings -> Plugins -> 搜索 "Claude Code" 并安装
  2. 重启 IDE
  3. 右侧工具栏会出现 Claude 图标

IDE 集成会自动继承终端中配置的环境变量(Bedrock 配置),无需额外设置。

七、常用技巧

7.1 让 AI 理解你的项目

在项目根目录创建 CLAUDE.md 文件,写入项目背景信息:

# 项目说明

- 这是一个 Spring Boot 微服务项目
- 使用 MyBatis 作为 ORM
- 数据库:MySQL 8.0
- 构建工具:Maven
- Java 版本:17

## 代码规范

- 遵循阿里巴巴 Java 开发手册
- Controller 层不写业务逻辑
- Service 层通过接口定义

Claude Code 每次启动时会自动读取该文件,确保 AI 理解项目上下文。

7.2 实用快捷键

快捷键 功能
Esc(连按两次) 退出 Claude Code
Cmd+C 中断当前 AI 响应
/help 查看所有可用命令
/clear 清空对话历史
/compact 压缩上下文(对话太长时使用)
Tab 自动补全文件路径

7.3 权限模式

Claude Code 在执行文件操作和 shell 命令时会请你确认:

  • 输入 y:允许本次操作
  • 输入 n:拒绝本次操作
  • 输入 !:本次会话中始终允许该类型操作

八、常见问题

Q1:报错 "Could not connect to Bedrock"

排查步骤:

# 1. 检查 AWS 凭证是否有效
aws sts get-caller-identity

# 2. 检查区域配置
echo $AWS_REGION

# 3. 检查 Bedrock 访问权限
aws bedrock list-foundation-models --region us-east-1 --query "modelSummaries[?contains(modelId, 'claude')]"

如果第 3 步报权限不足,请联系 DevOps 团队申请 Bedrock 模型访问权限。

Q2:报错 "Model not found" 或 "Access denied"

可能原因:

  • Bedrock 未开通对应模型 -> 联系 DevOps 开通
  • Region 不匹配 -> 确认 AWS_REGION 设置为 us-east-1
  • AWS 凭证过期 -> 重新执行 aws sso login

Q3:响应速度很慢

  • 切换到 Haiku 模型(更快、更便宜)
  • 检查网络连接,确保能访问 AWS
  • 使用 /compact 压缩过长的对话上下文

Q4:JSON 配置不生效

  • 确保 JSON 格式合法(可以用 jsonlint.com 校验)
  • 修改配置后必须重新打开终端
  • 检查配置文件路径是否正确

Q5:macOS 安装权限错误

# 方案一:使用 sudo
sudo npm install -g @anthropic-ai/claude-code --unsafe-perm

# 方案二:修改 npm 全局目录(推荐)
mkdir -p ~/.npm-global
npm config set prefix ~/.npm-global
echo 'export PATH=~/.npm-global/bin:$PATH' >> ~/.zshrc
source ~/.zshrc
npm install -g @anthropic-ai/claude-code

九、安全须知

  1. 不要在 Claude Code 对话中粘贴密码、Token 等敏感信息,AI 会将其作为上下文处理
  2. 不要让 AI 直接操作生产环境,所有生产操作请走正规发布流程
  3. 代码审查仍然必要,AI 生成的代码需要经过 Code Review 后才能合并
  4. 注意成本控制,避免无意义的长对话消耗 Token

十、获取帮助

场景 联系方式
AWS 凭证 / 权限问题 DevOps 团队

前端周刊:axios 疑遭朝鲜黑客“钓鱼“;CSS 新函数上线;npm 上线深色主题;Oxlint 兼容表;ESLint 支持 Temporal......

作者 Web情报局
2026年5月9日 20:16

🌐 今日要闻

打破信息壁垒,走近全球前端。Hello World 大家好,我是林语冰。

欢迎阅读《Web 周刊》,上周 Web 开发圈的主要情报包括:

  • 🐞 Axios 主席疑遭朝鲜黑客“钓鱼“,自爆了社会工程的诈骗过程
  • 🌗 npm 可信发布支持 CircleCI,npm 官网新增深色主题
  • ✅ ESLint 10.2 支持 JS 最新的 Temporal API
  • ✅ Axios 1.15 支持 Deno / Bun,源码重构了 url.parse()

PS:本文附带甜妹解说的动画视频,粉丝请搜索哔哩哔哩@Web情报局

🎉 每周热搜

🔗 Axios 主席疑遭朝鲜黑客“钓鱼“

Axios 是 GitHub 第一请求库,周下载量过亿。

不幸的是,愚人节前夕,Axios 突然发布了 2 个中毒版本,它们只坚挺了 3 小时就被封杀了,但至少波及几十万用户,这是近一年内最大规模的 npm 供应链攻击。

随后 Axios 团队主席爆料了攻击事件的完整事后分析,首先是它遭到了社会工程“钓鱼“,通过伪造的在线会议安装了有毒软件,导致 npm 账户被盗用。

axios-post.png

Axios 源码本身没有 bug,黑客只添加了一个幽灵依赖 plain-crypto-js,其中包含了一个 postinstall 脚本,随后使用传统 npm token 发布中毒版本。

用户使用 npm install axios 之后,postinstall 脚本会自动执行,请求其他恶意软件,盗用系统资料。

post-install.png

之后,这个脚本会自尽,删除 postinstall 脚本,替换为正常 package.json,用户对这种“完美犯罪“浑然不知。

谷歌和微软深入调查了本次赛博攻击,部分证据表明攻击来自朝鲜的黑客组织,但可能很难像川普打伊朗那样直接证明。

korea-npm.png

总之,供应链攻击是一种系统性原罪。我们应该遵循 npm 发包的最佳实践,防止黑客轻易绕过了现代化的可信发布流程。

🛜 官方情报

🔗 Node bug 悬赏项目破产

2016 年,Node 加盟了 HackerOne 的 IBB(互联网 bug 悬赏)项目,通过众筹为 fix bug 的志愿者提供奖金。

Node 团队会继续接收 bug,但由于资金链中断,该悬赏项目现已暂停。

node-bug.png

特别鸣谢一直以来为 Node 安全贡献的开发者和赞助商!Node 是 Web 开发的重要基建,如果你愿意提供赞助,请随时联系 OpenJS 基金会。

🔗 npm 可信发布支持 CircleCI

GitHub 官宣,npm 可信发布支持 CircleCI 作为 OIDC(OpenID 连接)供应商。

circle-ci.png

CircleCI 现在和 GitHub Actions 与 GitLab CI / CD 一样,维护者能从部署流程鉴权发包,无需长期 token。

此外,npm 官网上线深色主题了。

🔗 Oxlint / Oxfmt 兼容表

Oxc 官网新增了 Oxlint / Oxfmt 兼容表,可以直观地查看它们支持哪些 JS 框架和文件类型,从夯到拉分为四大梯队:

  1. 完整支持,比如 Oxlint + Oxfmt 完整支持 React 的代码质检和格式化
  2. 部分支持,比如 Oxlint 暂不支持 Vue 模板的代码质检
  3. 不支持,比如 Svelte 没有提供 Prettier 插件,Oxfmt 也不支持
  4. 越界功能,比如 Oxlint 不支持 CSS 代码质检

oxc.png

🚦 版本更新

🔗 ESLint 10.2

ESLint 是 GitHub 第一 JS Linter(代码质检工具),最近更新了 10.2 次版本。

首先,ESLint 新增了 meta.languages 属性,作者可以指定规则适用的语言,比如 JS 专属规则或 Markdown 专属规则等。

eslint.png

此外,ESLint 还支持 JS 最新的 Temporal 全局变量,no-undef 规则能识别 Temporal 而不会报警,no-obj-calls 规则会在直接调用 Temporal 时报警。

temporal.png

🔗 Axios 1.15

Axios 发布了 1.15 次版本,现在能支持 Bun / Deno。

Axios 还修复了代理处理和头部注入等安全漏洞,CI(持续集成)采用 OIDC 来守卫 npm 发布。

源码使用了原生 URL API,url.parse() 重构为 new URL()

url.png

💡 前端信息差

🔗 CSS 新函数 contrast-color()

最近 npm 上线了深色主题,但还有一些无障碍 bug,比如切换深色主题后文字对比度不够,看不清楚。

npm-a11y.png

Google 专家 Una 之前就提出了一个新的 CSS 函数 contrast-color(),它可以接受任意颜色,然后根据输入的颜色去计算对比度,最终返回 blackwhite

contrast.png

contrast-color() 可以自动生成对比度更好的文字,完美解决 npm 的无障碍文字 bug。

CSS contrast-color() 函数目前已经达到 Baseline Newly Available(全新可用基线),所有新版的主流浏览器都支持这个功能喔。

baseline.png

🛠️ 工具推荐

为了对抗供应链攻击,本期我们主要分享一些 npm 防御式指南

🔗 npm 可信发布

首先是 npm 官方文档出品的 trusted publishing(可信发布),它可以让 npm 和 GitHub CI(持续集成)完美搭配。

npm-trust.png

🔗 npmx 网站

再来是比 npm 更现代化的网站 npmx,npmx 会显示盾牌来说明这个 axios 版本是通过可信发布持续部署的。

你会发现 axios 的某些版本没有盾牌,说明 axios 没有严格使用可信发布,黑客就会从这里寻找机会。

npmx-oidc.png

npmx 不仅会警告你这个版本可信度降低,还会爆料模块的性能问题或安全漏洞,我知道你很急,但是你先别升级。

npmx-axios.png

🔗 pnpm 供应链攻击

还有就是比 npm 更现代化的包管理器 pnpm,pnpm v10 的官网提供了缓解供应链攻击的完整指南。

pnpm-trust.png

这些配置在最新版的 pnpm 11 中会默认启用,所以提前了解也方便你之后升级到 pnpm 11。

🔗 npm 安全最佳实践

最后,Node 安全专家 Liran 也在 GitHub 上分享了一份《npm 安全最佳实践》,建议收藏。

npm-best.png

🙏 特别鸣谢

以上就是本期《Web 周刊》的全部内容了,希望对你有所帮助。

👍 感谢大家按赞跟转发分享本文,你的手动支持是我坚持创作的不竭动力喔。

😘 已经关注我的粉丝们,我们下期再见啦,掰掰~~

cat-thank.gif

一个简单的套壳方案,就能让你的 Agent 少做重复初始化

作者 imoo
2026年5月9日 18:58

前言

随着 harness 的完善,Agent 的启动过程正在从“输入任务后直接执行”,逐渐变成“先完成一组初始化动作,再进入任务”。

常见初始化内容包括:

  • 个人记忆、协作偏好、输出风格
  • 项目背景、目录结构、技术栈、历史约定
  • 团队规范、工作流、提交和评审规则
  • 工具说明、MCP 配置、常用命令
  • 特定任务类型依赖的 skills / agents

这些内容大多不是一次性信息。它们变化不频繁,却会在每个新会话里反复出现。

问题也就集中在这里:

  • 重复:相同初始化内容在多个会话中反复执行。
  • 耗时:每次启动都要等待加载和确认。
  • 不稳定:步骤越多,越容易漏读、乱序或加载不完整。

当 Agent 使用越来越依赖记忆、项目上下文和工具体系后,初始化本身就变成了一个需要工程化处理的问题。

解决思路:复用稳定初始化

一次 Agent 任务可以拆成两部分:

  • 稳定初始化:记忆、规则、项目背景、工具、skills。
  • 具体任务:本次真正要做的分析、开发、写作或排查。

基座工程关注的是第一部分。

如果某些内容满足下面三个条件,就不应该每次都重新初始化:

  • 相对稳定
  • 高频使用
  • 启动时必需

更合理的方式是:先让 Agent 完成一次稳定初始化,把这个状态保存成基座;之后每个新任务都从这个基座派生。

可以把它理解成下面这条链路:

记忆 / 规则 / 项目上下文 / skills
        │
        ▼
   生成基座会话
        │
        ▼
  每次新对话,都 fork 基座会话
        │
        ▼
    执行具体任务

需要注意:基座复用优化的是初始化过程,不是让上下文内容本身消失。初始化内容仍然会成为会话状态的一部分。

实现方式:给 Codex 包一层启动器

要把这个思路落地,关键不是直接改 Codex 本身,而是在 Codex CLI 外面包一层启动器。

原因很简单:如果用户每次都直接执行 codex,启动前后没有地方插入自己的逻辑。我们无法在启动前判断基座是否过期,也无法自动决定应该生成基座、复用基座,还是从某个指定基座 fork。

所以需要一个自己的入口,负责在调用 Codex 之前先做一层调度。

在我的本地实现里,这个入口叫 cx。它不是新的 Agent,也不是 Codex 的替代品,只是 Codex CLI 外面的一层启动器:

用户输入 cx
   │
   ▼
cx 启动器:检查基座 registry、必要时刷新、解析别名
   │
   ▼
基于基座会话,派生新的工作会话,以免污染基座
   │
   ▼
进入真正的 Codex 任务会话

使用案例

下面举个具体的个人使用的例子。我的 harness 工程首轮对话会加载许多记忆、技能等,每次 codex 耗时约 30s,使用该方案就可以跳过这部分,从而直接聚焦于具体问题。

默认情况下,直接执行 cx,会从 memory 记忆基座派生新会话:

如果记忆相关的文件更新的情况下,则会通过文件哈希校验自动更新基座:

除此之外,我还添加了一套基座的管理命令,主要用于管理各个开发项目的上下文基座。

cx -add <session-id> <别名>     # 把某个已有 Codex 会话登记为基座
cx -list                       # 查看当前登记的基座
cx -load <别名|session-id>      # 从指定基座 fork 新会话
cx -remove <别名>               # 删除手动登记的基座

默认基座:memory

当前实现里,memory 是默认基座。用户不指定 -load 时,都会走它。

memory 基座是托管基座:

  • 没有本机状态时,首次执行 cx 会自动生成。
  • 关键初始化资产变化时,会自动刷新。
  • 它不能通过 -remove memory 删除,避免默认启动链路被误删。

机器 B 第一次使用时不需要从机器 A 复制 state。只要 repo 里的基座定义还在,执行 cx 时就会在机器 B 上重新生成自己的 memory 基座。

手动基座:通过别名管理

除了默认 memory,还可以把某次已有 Codex 对话登记成一个手动基座。

这类基座适合临时复用某个已经加载过上下文的会话,比如某个项目、某类排查现场或某次专项分析。

不过它和 memory 不一样:手动基座记录的是本机 Codex session id,本质上是本机状态,换机器后不能自动恢复。

所以可以把基座分成两类:

类型 生成方式 是否可跨机器自动恢复 适合场景
memory 默认基座 根据 repo 里的记忆和规则自动生成 可以重新生成 长期稳定初始化
手动基座 cx -add <session-id> <别名> 不可以,需要重新登记 临时项目现场、专项上下文

资产指纹:判断 memory 是否过期

对于默认 memory 基座,启动器会跟踪一组关键初始化资产。

const KEY_FILES = [
  "src/brain/claude.md",
  "src/brain/knowledge/claude.md",
  "src/config/skills/claude.md",
  "src/config/agents/claude.md",
  "src/brain/knowledge/reinforced-rules.md",
  "src/brain/knowledge/workflow-superpowers.md",
];

这些文件决定了 Agent 启动后要加载哪些记忆、遵守哪些规则、知道哪些 skills / agents。

启动器会对它们计算 hash,并合成一个整体指纹:

function computeFingerprint(root) {
  const lines = KEY_FILES.map(file => {
    const content = fs.readFileSync(path.join(root, file));
    return sha256(content) + "  " + file;
  });

  return sha256(lines.join("\n"));
}

指纹没变,说明 memory 基座仍然可用。

指纹变了,说明记忆、规则或 skill 索引发生变化,需要重新生成 memory 基座。

哪些内容适合被基座化

适合进入基座的内容,通常满足三个条件:

  • 稳定:变化频率低。
  • 高频:多个任务都会用到。
  • 必要:缺失后会明显影响执行质量。

常见基座类型:

类型 适合放入的内容
个人基座 长期记忆、协作偏好、输出风格、常用工作习惯
项目基座 项目背景、目录结构、技术栈、常用命令、开发约定

不适合进入基座的内容也要明确:

  • 单次任务里的临时信息
  • 尚未确认的推测
  • 变化频繁的业务细节
  • 只对当前会话有价值的过程记录

简单判断标准是:如果一段内容下次大概率还会用到,并且启动时就需要知道,它就有机会被基座化。

给 AI Agent 装上"长期记忆":Karpathy 的 LLM Wiki 思想,我做成了工具

2026年5月9日 18:30

你的 AI 每次对话都在重新推导知识。而一个由 Agent 自己维护、会复利增长的 Wiki,让它越用越聪明。

这篇文章不是教你怎么敲 CLI 命令。memex 的入口在 agent 对话里——你只需要说 /memex:capture/memex:ingest/memex:query,Agent 自己知道怎么做。


一、Karpathy 在 2026 年 4 月提出了一个思想

HE9kEdZaMAADLIU.jpg

Andrej Karpathy 是 OpenAI 创始团队成员、前 Tesla AI 总监。2026 年 4 月 4 日,他在 GitHub Gist 上发布了一篇 LLM Wiki Pattern,系统阐述了一个思想:

为什么人类用 Wiki 积累知识,而 AI 每次对话都在从零推导?

他的主张很直接:给 LLM 一个结构化 Markdown Wiki,让它自己维护。人类只负责往 raw/ 里扔源材料,LLM 负责把知识编译进 wiki/——更新概念页、建立交叉引用、标注矛盾、写综合页。每轮对话不是"检索",是"阅读一本已经写好的书"。

他打了个比方,传得很广:

"Obsidian is the IDE, the LLM is the programmer, the wiki is the codebase."

翻译过来就是:"Obsidian 是 IDE,LLM 是程序员,Wiki 是代码库。"

什么意思?你写代码时——IDE 是你的界面,程序员是写代码的人,代码库是持续构建的产物。类比到这里——Obsidian(或任意 Markdown 浏览器)只是你看知识的界面,LLM 才是真正写知识的人,Wiki 就是 LLM 持续构建和维护的知识产物。你不写 Wiki,你看 Wiki;LLM 不读 Wiki,LLM 写 Wiki。

Karpathy 的核心洞见其实用一句话就能说清——他把知识库当代码仓库管理:

软件工程 知识库工程
src/ raw/(原始资料,不可变)
build/ wiki/(编译产物,LLM 自动生成)
编译器 LLM(把 raw 编译成结构化 wiki)
IDE Obsidian / 任意 Markdown 浏览器
Lint / CI 健康检查(断链、矛盾、过期页)
增量编译 每次只 ingest 新增的 raw,不改旧文件

我是开发出身,第一眼看到这张表就懂了。这不就是 CI/CD 的知识库版本吗?

软件工程 → 知识库工程 映射

而 Karpathy 用了一个词来概括这一切——编译(Compile)。把原始资料编译成结构化知识。raw 是源码,wiki 是编译产物。你不会把 .class.java 混在一起,笔记也一样。

核心区别在于:RAG 每次重推,Wiki 持续复利。

这句话拆开看——

RAG LLM Wiki
知识形态 文档切片,无关联 结构化页面,交叉引用
更新方式 重新索引 Agent 直接编辑 Markdown
查询 向量相似度拼凑 读已组织好的页面
累积性 没有复利 每次 ingest 在旧知识上修改、关联
所有权 在厂商的向量库里 在本地 Git 仓库里

Karpathy 给的是思想。我把它做成了工程:memex


二、memex 怎么用?在 agent 对话里说话就行

最重要的概念先摆出来——

你不是在终端敲 memex distillmemex ingest。你是在 agent 对话框里说 /memex:capture/memex:ingest/memex:query。CLI 只在 Agent 脚下跑,你感觉不到它。

memex 提供了 6 个 slash command,覆盖完整的知识生命周期:

Slash Command 你做什么 Agent 做什么
/memex:capture 给 Agent 一个 URL、一段文字、一个文件 Agent 保存到 raw/,记录出处,不变形
/memex:ingest "把这些新东西消化进知识库" Agent 读 raw 源材料,更新 concept/entity/source 页面,写交叉引用,更新 index
/memex:query "关于 X,我们知道哪些?" Agent 搜 wiki,综合答案,带引用
/memex:distill "这次对话有不少好结论,存下来" Agent 把会话要点蒸馏成结构化 raw 笔记
/memex:lint "检查一下知识库健不健康" Agent 跑机械检查 + 语义扫描,报问题,修问题
/memex:status "看看知识库现在什么状态" Agent 报告页面数、最近变化、待处理项

你不需要记住命令参数。你只需要用自然语言告诉 Agent 你想干什么,Agent 自己调对应的 slash command。

别上来就搞 RAG

一提"AI + 笔记",很多人的第一反应是搭 RAG:选 Embedding 模型、搭向量数据库、调切片策略。整套架构搞了一个月,笔记库里还是只有 20 篇文章。

Karpathy 的思路反过来:先跑通流程,再优化基础设施。 知识库规模不大的时候(几百篇文章以内),维护几个索引文件就够了。LLM 先读 index.md 定位,再直接阅读相关内容。简单、可靠、零额外成本。等你的笔记真的过了一万条,搜东西开始找不到、找不全了,再考虑 RAG 不迟。

这道理写代码的人都懂,但轮到自己搭知识库的时候就忘了。

每次问答也能存回知识库

还有一个 Karpathy 特别强调的设计:好的问答结果应该存回 wiki,而不是消失在聊天记录里。 你问了一个复杂问题,Agent 查 wiki、综合答案、带引用——这个答案本身就是一份有价值的知识产物。把它存成新页面。下次类似问题,Agent 直接读已有的分析,不用重新推导。

你每跟 AI 聊一次,知识库就增加一层。这就是复利。

知识编译管线:capture → ingest → query → lint


三、五个场景:memex 到底能带来什么价值

下面这五个场景,是我自己用了三个月的真实感受。

场景 1:长期研究 —— 让知识库自己长起来

痛点:你在研究"Agent Memory vs RAG"这个话题,今天看一篇论文,明天读一个开源项目,后天和 AI 讨论两个小时。三周后你想写篇总结文章——发现所有讨论散落在十几个聊天窗口里,找不到线索。

怎么做

你:/memex:capture https://arxiv.org/abs/xxxx --scene research
你:读到新的论文或讨论出新想法时,继续 capture 进去
你:积累几份材料后——
你:/memex:ingest 把这些新研究材料消化进 wiki
你:/memex:query "agent memory 和 RAG 的设计取舍,我们目前知道哪些?"

你始终在 agent 对话里。Agent 负责:

  • 把每篇论文、每次讨论存成 raw/research/ 下的源文件
  • ingest 时把新知识合并进 concepts/agent-memory.md、更新对比页 summaries/agent-memory-vs-rag.md
  • query 时综合 wiki 里的所有内容,带引用回答

价值:三周后,你拥有的不是十几个聊天窗口,而是一个结构化的知识地图——概念定义、方案对比、源材料索引、开放问题清单。写文章时,直接 /memex:query "agent memory 技术路线对比"

场景1:长期研究 — 知识库随时间生长

场景 2:长期项目 —— 让项目记忆可继承

痛点:你的项目已经迭代了三个月。今天用 Claude Code,明天用 Codex,后天用 Cursor。每个新 Agent 都要重新理解架构、踩过的坑、命名的原因、测试的边界。

怎么做

你:帮我连接这个项目到 memex 知识库
Agent:安装项目级别的 context 文件,记录相关的 scene

你:读当前代码和文档,然后起草这个项目的 architecturecommand-designknown-pitfalls 页面
Agent:读源码,写带有文件路径引用的 code-reading 笔记到 raw/

你:/memex:ingest 把这次 code-reading 结论写进项目 wiki
Agent:更新架构决策页、命令设计页、已知坑页、测试契约页

每次新会话开始:

你:/memex:query "继续 ai-memex-cli 网站和文档工作"
Agent:从 wiki 拉出最近的 handoff 笔记、未完成的任务、需要遵守的测试契约
你:从上次中断的地方继续

价值:项目知识不再是散落在聊天里的只言片语。新 Agent 开局就能回答"为什么这么设计"、"哪些地方容易踩坑"、"上次改到哪了"。代码仓库本身就是 source of truth,wiki 存的是 Agent 从代码、文档、issue、反馈中提炼出来的可继承理解

场景2:长期项目 — 三个 Agent 共用一个 wiki

场景 3:跨会话继承 —— 多次会话之间携带上下文

痛点:今天 Claude Code 做了一半,明天 Codex 继续,后天出差回来用 Cursor 检查。每个新会话都是一个黑洞——上下文全丢。

怎么做

你:/memex:distill 这次 Codex 会话,写清楚做到了哪、下一步做什么、有没有阻塞
Agent:找到当前 agent 的会话数据,蒸馏成 raw/sessions/ 下的结构化笔记

你:/memex:ingest 把这次 handoff 合并进项目记忆
Agent:更新项目 wiki 中的进度页和 log.md

——第二天,换了一个 agent——

你:/memex:query "上次中断的工作,下一步是什么"
Agent:从 wiki 里拉出 handoff 笔记和未完成项

跨 Agent 完全无感——Claude Code 写的,Codex 能读;Codex 补充的,Cursor 继续改。它们不共享一个聊天窗口,它们共享 raw/wiki/index.mdlog.md

价值:连续性不再绑定任何一个厂商。你可以换 Agent、换模型、等一周再回来,任务状态还在同一个 wiki 里等你。

场景3:跨会话继承 — 有无 memex 的对比

场景 4:对话沉淀 —— 把聊天里的好结论留下

痛点:一场深入对话里,你们讨论了产品定位、架构边界、某个 bug 根因、三个被否决的方案。聊完很爽,一周后只记得大概——细节全丢了。

怎么做

你:/memex:distill 这次对话,我们聊清楚了产品定位和几个关键的取舍
Agent:把对话蒸馏成 source 页,保留上下文、决策、未解问题

你:/memex:ingest 只要这次确定的稳定结论,合并到已有的 positioning 页面里
Agent:读蒸馏产物,提取可复用的结论,增量更新已有页面,不复制已有内容

什么样的结论值得沉淀?

  • 产品定位:怎么描述产品、避免用什么说法
  • 架构边界:为什么 CLI 不做语义层、为什么 raw 不可变
  • Bug 根因:排查路径、实际原因、回归测试要点
  • 被否决的方案:为什么没选、当时的前提是什么

价值:聊天不再是消耗品。重要推理先变成可追溯的 source,再变成结构化的 wiki 知识。下次 query 时,能同时看到结论和它为什么成立。如果前提变了,wiki 也能记录"老判断基于什么、新判断基于什么"。

场景4:对话沉淀 — 从聊天到 wiki 的蒸馏流

场景 5:结构化维护 —— 让 Agent 持续维护知识,而不是只回答一次

痛点:大部分人用 AI 的模式是"问一次答一次"。知识在回答完后原地消失。没人去更新、去合并重复页、去修断链、去标记过期内容。

怎么做

你:/memex:status
Agent:报告 vault 整体健康状况——页面数、最近更新的 source、哪些页面过时了、哪些维护任务待处理

你:/memex:lint 检查断链、孤儿页、过期页、缺失的 frontmatter
Agent:跑机械 lint(路径、链接、frontmatter 正确性)+ 语义扫描(矛盾、重复、过时论断)
你:机械问题直接修,语义问题先给我看方案
你:把 Karpathy 的 LLM Wiki gist 加入知识库
Agent:capture 源文件 → 创建 concept 页 → 更新相关页面交叉引用 → 写 log
你:告诉我改了什么,还有什么需要 review

价值:Wiki 不是一堆文件的堆积。它是一个被持续维护的结构化系统。每次 Agent 用它,也能同时改善它。重复页被合并或标注、孤儿页被找到、断链被修复、index.md 是真正的导航入口而非文件列表。

场景5:结构化维护 — lint 健康检查的四个维度


四、Agent 和 CLI 的分工边界

这里有一个设计决策需要讲清楚——CLI 永远只做机械正确性的事,不做语义判断。

谁负责 做什么
Agent Claude Code / Codex / Cursor 判断哪些页面要更新、哪些概念要链接、哪些矛盾要保留、哪些总结要重写
Slash Command /memex:capture 等 6 个 把用户的自然语言意图翻译成底层 CLI 调用
CLI memex 命令行工具 文件读写、frontmatter 校验、链接检查、关键词搜索、会话解析——纯机械,不调 LLM API

这意味着:

  • 你的知识不绑定任何厂商——Agent 可以换,wiki 不变
  • 你的知识是 Git 化的 Markdown——可以 diff、可以 blame、可以回退
  • CLI 永远不帮你做语义决策——"这两个页面是不是该合并"这种问题,Agent 自己判断但会问你

memex 三层架构:raw(不可变)→ wiki(编译)→ 输出

CLI 的补充能力

上面 6 个 slash command 覆盖日常 90% 的交互。CLI 底层还提供几个高阶能力,但不建议作为日常入口:

CLI 命令 用途 说明
memex watch 自愈守护进程 监听 raw/ 变化,自动触发 ingest → lint 循环。适合长期跑
memex inject 上下文注入 会话开始前,按任务描述从 wiki 拉最相关页面注入当前上下文
memex install-hooks 安装 Agent hooks 把 SessionStart / SessionEnd hook 写入 Agent 配置,自动 distill 和 inject
memex search 命令行搜索 全文搜索 wiki,适合脚本化场景

但这些不是入口。日常入口是 agent 对话框,是说 /memex:query 而不是敲 memex search


五、两周跑通最小闭环

如果你想试,不需要什么额外工具。装好 memex,在你的 Agent 里说话就行。

第一周:搭 raw → wiki 的最小循环。 装好 memex,运行 memex onboard。然后开始往知识库喂东西——看到好文章、好推文、好想法,直接对 Agent 说 /memex:capture。攒够 5 到 10 条后,说 /memex:ingest 把这些新素材消化进知识库。Agent 会生成摘要、提取概念、更新索引。

第二周:让问答开始积累,跑第一次健康检查。 每次对知识库做复杂提问,结果让 Agent 存回 wiki。然后说 /memex:lint 给知识库做一次全面体检。Agent 会扫出断链、矛盾、过期页、孤儿页——先让它修机械问题,语义问题你看一下再决定。

两周之后你有一个能持续运转的小系统。规模不重要,流程跑通了就行。后面就是往 raw/ 里不断喂素材,让 Agent 持续编译。


六、知识库的"GitHub 时刻"

回到 Karpathy。他那篇 Gist 的最后一句话是:

这套东西目前仍然像一堆 hacky scripts,但有空间做成 incredible new product。

我想到 2006 年前的版本控制。那时候也是 svn、cvs、git 命令行,只有程序员在用。然后有人把它做成了 GitHub,整个协作方式都变了。

个人知识库可能正在类似的节点。今天它是 Obsidian + LLM + 手搓脚本的组合,看起来还很粗糙。但底层范式已经有了:把知识当代码管理。 有输入,有编译,有产物,有测试。

如果你是程序员,好消息是你不需要学任何新东西。代码仓库怎么管,知识库就怎么管。你积累了这么多年的工程直觉,终于可以用在自己的笔记上了。

Karpathy 原文里还有一段话:

人类放弃 Wiki 是因为维护负担的增长速度永远超过它带来的价值。你得亲手写每个页面、手动保持一致性、记住所有交叉引用。

但 LLM 不会无聊。它可以一次触碰 10-15 个页面,把新知识合并进去,更新索引,同时保持系统自洽。

人的工作:策展、取舍、提问、思考。LLM 的工作:剩下的全部。

memex 做的,就是把这句话变成可以跑的东西。

别让你的笔记腐烂。让它们被编译。


快速开始:

npm install -g ai-memex-cli
memex onboard

然后在你的 Claude Code / Codex / Cursor 里说第一句话:

你:/memex:capture https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f --scene research
你:/memex:ingest Karpathy 的 LLM Wiki 思想,作为 research 场景的第一份材料
你:/memex:query "Karpathy 的 LLM Wiki 核心思想是什么?"

给 AI 一份会生长的记忆。


项目地址: github.com/zelixag/ai-…

理念来源: Karpathy's LLM Wiki Pattern

大模型和function calling分别是如何工作的

作者 review44543
2026年5月9日 18:02

以一个表处理为例:

github地址:github.com/YueJingGe/p…

用大模型处理表格

描述:有一个本周饮品销量小表,希望大模型输出按饮品汇总销量的列表,以及销量最高的饮品是什么

代码示例+运行结果

image.png

核心思想

当你把表格 用 df.to_string() 转成文本,然后放进 prompt 里时,大模型(如通义千问、GPT-4)会做这几步:

  • 1、阅读理解:识别出这是一个三列表格,理解列名含义(“饮品”“销量_杯”“门店”)。
  • 2、信息提取:逐行读取数据,记住每行对应关系。
  • 3、简单计算:将“美式”的两行销量 30 和 12 相加,得到 42;将“拿铁”的 18 单独作为总和;将“橙汁”的 12 作为总和。
  • 4、格式组织:按要求的格式输出“美式:42,拿铁:18,橙汁:12”。   这些能力完全来自大模型自身的训练——它在海量数据中学到了如何理解文本表格、如何进行基本的聚合计算(求和、计数等)、如何比较大小。你不需要额外实现任何逻辑,只要把表格文本喂给模型,它就能尝试回答。  

技术架构图

image.png

局限性

虽然模型能处理简单的表格问答,但有以下问题:

  • 1、计算错误:当表格行数多、数字复杂(如带小数、需加权平均),或者需要多步运算(先分组再排序再筛选),模型容易算错或遗漏。
  • 2、上下文长度:表格太大(比如1000行),放进 prompt 可能超过模型的 token 限制,或导致注意力分散、遗漏关键信息。
  • 3、不确定性:同一个表格问同一个问题,模型可能给出略有差异的答案(因为生成有随机性)。
  • 4、无法执行复杂逻辑:比如“找出销量最高的门店,再列出该门店销量前两名的饮品”,这类需要多步条件筛选和排序的任务,模型往往做不好。  

Function Calling + 大模型处理表

首先,Function calling(也称工具调用)不是让模型直接分析表格,而是让模型决定去调用一个外部工具(比如写好的 Python 函数) 来精确处理表格,再把工具返回的结果整理成自然语言回答。

描述:有一个本周饮品销量小表,希望大模型输出按饮品汇总销量的列表,以及销量最高的饮品是什么,以及哪个门店销量最高

典型流程:

用户问:“按饮品汇总销量。”
模型看到可用的函数列表,比如 compute_groupby_sum(column, group_by)。
模型不自己计算,而是输出一个函数调用指令:compute_groupby_sum(column="销量_杯", group_by="饮品")。
你的程序拦截这个指令,让 Python 后端实际执行 df.groupby("饮品")["销量_杯"].sum(),拿到精确结果 {"美式":42, "拿铁":18, "橙汁":12}。
你的程序把这个结果送回模型,模型再组织成一句人话:“美式共卖出42杯,拿铁18杯,橙汁12杯。”

代码示例

import os
import json
import pandas as pd
from openai import OpenAI
from dotenv import load_dotenv

load_dotenv()
client = OpenAI(
    api_key=os.getenv('DASHSCOPE_API_KEY'),
    base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
)

# ---------- 定义真实的数据查询函数(后端执行) ----------
# 使用 pandas 创建数据表
df = pd.DataFrame({
    "饮品": ["美式", "拿铁", "橙汁", "美式"],
    "销量_杯": [30, 18, 12, 12],
    "门店": ["A店", "A店", "B店", "B店"],
})

def get_sales_summary(by: str = "饮品"):
    """
    获取销量汇总数据。
    by: 分组依据,目前支持 "饮品" 或 "门店"
    """
    if by == "饮品":
        grouped = df.groupby("饮品")["销量_杯"].sum().reset_index()
        grouped = grouped.sort_values("销量_杯", ascending=False)
        # 转为字典列表,方便模型阅读
        result = grouped.to_dict(orient="records")
    elif by == "门店":
        grouped = df.groupby("门店")["销量_杯"].sum().reset_index()
        grouped = grouped.sort_values("销量_杯", ascending=False)
        result = grouped.to_dict(orient="records")
    else:
        result = {"error": f"不支持的聚合方式: {by}"}
    return json.dumps(result, ensure_ascii=False)

def get_top_sales(by: str = "饮品"):
    """获取销量冠军"""
    if by == "饮品":
        grouped = df.groupby("饮品")["销量_杯"].sum()
        top_item = grouped.idxmax()
        top_value = int(grouped.max())
        return json.dumps({"冠军": top_item, "销量": top_value}, ensure_ascii=False)
    elif by == "门店":
        grouped = df.groupby("门店")["销量_杯"].sum()
        top_item = grouped.idxmax()
        top_value = int(grouped.max())
        return json.dumps({"冠军": top_item, "销量": top_value}, ensure_ascii=False)
    else:
        return json.dumps({"error": f"不支持的冠军查询: {by}"})

# ---------- 定义工具描述(给大模型看的说明书) ----------
tools = [
    {
        "type": "function",
        "function": {
            "name": "get_sales_summary",
            "description": "按饮品或门店获取销量汇总,返回排序后的列表",
            "parameters": {
                "type": "object",
                "properties": {
                    "by": {
                        "type": "string",
                        "enum": ["饮品", "门店"],
                        "description": "分组维度,例如'饮品'或'门店'"
                    }
                },
                "required": ["by"]
            }
        }
    },
    {
        "type": "function",
        "function": {
            "name": "get_top_sales",
            "description": "获取销量冠军(销量最高的饮品或门店)",
            "parameters": {
                "type": "object",
                "properties": {
                    "by": {
                        "type": "string",
                        "enum": ["饮品", "门店"],
                        "description": "冠军类型,例如'饮品'或'门店'"
                    }
                },
                "required": ["by"]
            }
        }
    }
]

# ---------- 主对话循环 ----------
def ask_with_function_calling(user_question):
    messages = [{"role": "user", "content": user_question}]
    
    # 第一次调用:让模型判断是否需要调用函数
    first_response = client.chat.completions.create(
        model="qwen-plus",  # 或 qwen-turbo
        messages=messages,
        tools=tools,
        tool_choice="auto",
    )
    response_message = first_response.choices[0].message
    messages.append(response_message)
    
    # 模型要求调用函数吗?
    if response_message.tool_calls:
        for tool_call in response_message.tool_calls:
            func_name = tool_call.function.name
            func_args = json.loads(tool_call.function.arguments)
            print(f"🔧 模型调用: {func_name}, 参数: {func_args}")
            
            # 执行真实的 Python 函数
            if func_name == "get_sales_summary":
                result = get_sales_summary(**func_args)
            elif func_name == "get_top_sales":
                result = get_top_sales(**func_args)
            else:
                result = json.dumps({"error": "未知函数"})
            
            # 将工具结果加入对话
            messages.append({
                "role": "tool",
                "tool_call_id": tool_call.id,
                "content": result,
            })
        
        # 第二次调用:模型根据工具结果生成最终回答
        second_response = client.chat.completions.create(
            model="qwen-plus",
            messages=messages,
        )
        final_answer = second_response.choices[0].message.content
        return final_answer
    else:
        # 没有工具调用,直接返回模型回答
        return response_message.content

# ---------- 运行示例 ----------
if __name__ == "__main__":
    # 问题1:按饮品汇总销量
    q1 = "按饮品汇总销量,并告诉我销量最高的饮品是什么"
    print(f"🙋 用户: {q1}")
    ans1 = ask_with_function_calling(q1)
    print(f"🤖 AI: {ans1}\n")
    
    # 问题2:销量冠军门店
    q2 = "哪个门店的销量最高?"
    print(f"🙋 用户: {q2}")
    ans2 = ask_with_function_calling(q2)
    print(f"🤖 AI: {ans2}\n")

运行结果:

image.png

核心思想

核心思想就是:

1、你给模型预定义一组函数,告诉模型这些函数能做什么。

2、用户问自然语言问题,比如“按饮品汇总销量,然后自然地说出来”。

3、大模型自己决定该不该调用函数、调用哪个函数、传什么参数。

4、模型返回的不是最终答案,而是一个函数调用指令(例如 get_sales_summary(by='drink'))。

5、你的代码收到这个指令后,实际去执行 pandas 计算(或查数据库、调API等),然后把精确的计算结果再发回给模型。

6、模型根据结果生成最终的自然语言回答。

整个过程中,模型在主动决策“我需要调用工具来帮忙”,而不仅仅是接收现成数据。

技术架构图

image.png

总结

不用 Function Calling 的时候主要是依赖大模型本身的语言理解和推理能力,适合你完全清楚要算什么的场景,代码简单高效。

用 Function Calling是作为一种“增强手段”来提升准确性和能力上限,适合搭建对话式数据分析助手,用户可以随意提问,模型自动选择合适的函数去执行。

深入学习

Function Calling 官方资料:

阿里云百炼平台:搜索“阿里云百炼 Function Call”,参考官方文档。

社区博客:搜索“超实用!用 FunctionCall 实现快递 AI 助手”,了解用它构建真实 AI 助手的详细步骤。

CSDN 博客:搜索“通义千问的 Function Call”,更直观地理解整个实现流程。

用ethers.js连接MetaMask实现Web3钱包登录:从踩坑到稳定运行的完整记录

作者 竹林818
2026年5月9日 18:01

前言:一个看似简单的需求,让我折腾了两天

事情是这样的:上个月我在做一个DeFi看板项目,核心功能是让用户连接MetaMask钱包后,能看到自己的资产和交易记录。老板说"钱包登录很简单,就是调个接口嘛",我当时也觉得——不就是调个window.ethereum吗?结果真正动手时,踩坑踩到怀疑人生。

从连接失败、签名验证错误,到用户拒绝连接后状态混乱,再到不同链之间切换导致数据错乱……每一个问题都让我抓狂。但最终,我总结出了一套稳定可用的方案。这篇文章就是把我这两天的踩坑经历完整记录下来,希望对你有帮助。


背景:一个DeFi看板项目的前端需求

当时我正在开发一个叫"DeFiDash"的项目,本质上就是一个聚合看板,用户连接钱包后能查看自己在多个链上的资产、持仓和交易记录。前端用的是React + TypeScript,后端是Node.js。

核心需求其实就三个:

  1. 用户点击"连接钱包"按钮,弹出MetaMask授权窗口
  2. 用户签名一条消息,后端验证签名后返回JWT token
  3. 页面根据用户地址展示对应的链上数据

看起来很简单对吧?但真正实现时,我遇到了第一个大坑——连接过程的状态管理。


问题分析:为什么"一行代码"搞不定?

我最初的思路是直接写一个connectWallet函数:

// 第一版代码,天真到不行
async function connectWallet() {
  const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
  setAddress(accounts[0]);
}

然后我发现了三个问题:

问题1:用户拒绝连接时,代码会直接崩溃。 如果用户点击了MetaMask弹窗的"取消"按钮,request会抛出一个错误,但我的代码没有捕获它,导致页面白屏。

问题2:连接后没有验证链ID。 用户可能连接的是以太坊主网,也可能连接的是Goerli测试网,但我根本没有检查。后来用户反馈说"连接后看不到资产",排查了半天才发现是链ID不匹配。

问题3:页面刷新后连接状态丢失。 用户连接成功后,刷新页面就需要重新连接。这体验太差了,而且每次刷新都弹MetaMask窗口,用户会疯的。

这三个问题让我意识到,钱包登录远不止"调一个接口"那么简单。我需要一个完整的连接流程,包括状态管理、错误处理、链ID验证和持久化。


核心实现:一步步搭建稳定的钱包登录

第一步:初始化Provider和检测MetaMask

在React中,我习惯把所有Web3相关的逻辑封装在一个自定义Hook里。首先,我需要一个provider——这是与区块链交互的底层对象。

// hooks/useWallet.ts
import { BrowserProvider, JsonRpcSigner } from 'ethers';
import { useState, useEffect, useCallback } from 'react';

export function useWallet() {
  const [provider, setProvider] = useState<BrowserProvider | null>(null);
  const [signer, setSigner] = useState<JsonRpcSigner | null>(null);
  const [address, setAddress] = useState<string>('');
  const [chainId, setChainId] = useState<number>(0);
  const [error, setError] = useState<string>('');

  // 初始化:检测MetaMask是否安装
  useEffect(() => {
    if (typeof window.ethereum === 'undefined') {
      setError('请安装MetaMask浏览器插件');
      return;
    }
    // 注意:这里不要自动请求连接,只在用户点击按钮时才触发
    const ethersProvider = new BrowserProvider(window.ethereum);
    setProvider(ethersProvider);
  }, []);
}

这里有个坑: 不要一加载页面就调用eth_requestAccounts。有些用户不想连接钱包,只是浏览页面,自动弹窗会吓到他们。正确的做法是只检测MetaMask是否存在,连接操作交给用户点击按钮触发。

第二步:实现连接逻辑,处理所有异常

连接钱包的核心是调用eth_requestAccounts,但必须做好错误处理。我当时用了一个笨办法——直接在catch里打印错误信息,后来发现不同错误类型需要不同处理方式。

// hooks/useWallet.ts(续)
const connect = useCallback(async () => {
  if (!provider) {
    setError('Provider未初始化');
    return;
  }

  try {
    // 关键:先请求账户,再获取签名者
    const accounts = await provider.send('eth_requestAccounts', []);
    if (accounts.length === 0) {
      throw new Error('没有获取到账户');
    }

    const userAddress = accounts[0];
    const userSigner = await provider.getSigner();
    const network = await provider.getNetwork();
    const userChainId = Number(network.chainId);

    // 验证链ID是否在支持的范围内
    const SUPPORTED_CHAIN_IDS = [1, 5, 137]; // 以太坊主网、Goerli、Polygon
    if (!SUPPORTED_CHAIN_IDS.includes(userChainId)) {
      // 这里可以提示用户切换网络,但先保存状态
      console.warn(`当前链ID ${userChainId} 不在支持列表中`);
    }

    setAddress(userAddress);
    setSigner(userSigner);
    setChainId(userChainId);
    setError('');

    // 持久化:把地址存到localStorage,下次刷新时自动恢复
    localStorage.setItem('walletAddress', userAddress);
    localStorage.setItem('walletChainId', userChainId.toString());

  } catch (err: any) {
    // 处理不同类型的错误
    if (err.code === 4001) {
      // 用户拒绝了连接请求
      setError('用户拒绝了连接请求');
    } else if (err.code === -32002) {
      // MetaMask正在处理另一个请求
      setError('请先处理MetaMask中的其他请求');
    } else {
      setError(err.message || '连接钱包失败');
    }
  }
}, [provider]);

注意这个细节: 错误码4001是用户拒绝,-32002是重复请求。这两个错误码我查了MetaMask文档才搞清楚,之前一直用err.message判断,结果发现不同版本的MetaMask返回的消息格式不一样。

第三步:消息签名与后端验证

登录不只是连接钱包,还需要让后端验证用户身份。最常用的方式是"消息签名"——前端让用户签名一条包含随机数(nonce)的消息,后端用公钥验证签名。

// hooks/useWallet.ts(续)
const signMessage = useCallback(async (message: string): Promise<string> => {
  if (!signer) {
    throw new Error('请先连接钱包');
  }

  try {
    // 注意:message应该包含一个nonce,防止重放攻击
    const signature = await signer.signMessage(message);
    return signature;
  } catch (err: any) {
    if (err.code === 4001) {
      throw new Error('用户取消了签名');
    }
    throw new Error('签名失败: ' + err.message);
  }
}, [signer]);

// 实际登录流程
const login = useCallback(async () => {
  if (!address) {
    setError('请先连接钱包');
    return;
  }

  try {
    // 1. 从后端获取nonce
    const nonceResponse = await fetch('/api/auth/nonce?address=' + address);
    const { nonce } = await nonceResponse.json();

    // 2. 让用户签名nonce
    const message = `欢迎登录DeFiDash,本次登录的随机码为:${nonce}`;
    const signature = await signMessage(message);

    // 3. 发送地址和签名到后端验证
    const loginResponse = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ address, signature, message }),
    });

    const { token } = await loginResponse.json();
    // 存储token,后续API请求带上
    localStorage.setItem('authToken', token);
    setError('');

  } catch (err: any) {
    setError(err.message || '登录失败');
  }
}, [address, signMessage]);

这里有个坑: 签名消息的格式很重要。有些项目直接签address,这太不安全了,因为任何网站都可以伪造。一定要包含nonce,并且最好加上一些上下文信息(比如"欢迎登录XXX"),让用户在MetaMask里能看到清晰的内容。

第四步:页面刷新后自动恢复连接状态

用户连接成功后刷新页面,如果直接显示"未连接",体验很差。我通过localStorage保存地址,在页面加载时尝试恢复。

// hooks/useWallet.ts(续)
// 页面加载时恢复连接
useEffect(() => {
  const savedAddress = localStorage.getItem('walletAddress');
  const savedChainId = localStorage.getItem('walletChainId');

  if (savedAddress && provider) {
    // 恢复时只设置地址,不主动请求连接
    setAddress(savedAddress);
    setChainId(Number(savedChainId));
    // 注意:这里不设置signer,因为signer需要用户授权
    // 实际使用时,如果用户需要签名,再调用connect获取signer
  }
}, [provider]);

// 监听账户变化和链变化
useEffect(() => {
  if (!window.ethereum) return;

  const handleAccountsChanged = (accounts: string[]) => {
    if (accounts.length === 0) {
      // 用户断开了连接
      disconnect();
    } else {
      setAddress(accounts[0]);
      localStorage.setItem('walletAddress', accounts[0]);
    }
  };

  const handleChainChanged = (chainIdHex: string) => {
    const newChainId = parseInt(chainIdHex, 16);
    setChainId(newChainId);
    localStorage.setItem('walletChainId', newChainId.toString());
    // 链变化后,signer需要重新获取
    if (provider) {
      provider.getSigner().then(setSigner);
    }
  };

  window.ethereum.on('accountsChanged', handleAccountsChanged);
  window.ethereum.on('chainChanged', handleChainChanged);

  return () => {
    window.ethereum.removeListener('accountsChanged', handleAccountsChanged);
    window.ethereum.removeListener('chainChanged', handleChainChanged);
  };
}, [provider]);

const disconnect = useCallback(() => {
  setAddress('');
  setSigner(null);
  setChainId(0);
  localStorage.removeItem('walletAddress');
  localStorage.removeItem('walletChainId');
  localStorage.removeItem('authToken');
}, []);

注意这个细节: chainChanged事件返回的是十六进制字符串(比如"0x5"),需要转成十进制。我当时直接用了parseInt,但忘记加基数参数,导致"0x5"被解析成0。排查了半天才发现。


完整代码:可直接运行的React Hook

我把上面所有代码整合成一个完整的useWallet Hook,你可以直接复制到项目中使用。

// hooks/useWallet.ts
import { BrowserProvider, JsonRpcSigner } from 'ethers';
import { useState, useEffect, useCallback } from 'react';

interface WalletState {
  provider: BrowserProvider | null;
  signer: JsonRpcSigner | null;
  address: string;
  chainId: number;
  error: string;
  isConnecting: boolean;
}

export function useWallet() {
  const [state, setState] = useState<WalletState>({
    provider: null,
    signer: null,
    address: '',
    chainId: 0,
    error: '',
    isConnecting: false,
  });

  // 初始化:检测MetaMask
  useEffect(() => {
    if (typeof window.ethereum === 'undefined') {
      setState(prev => ({ ...prev, error: '请安装MetaMask插件' }));
      return;
    }
    const ethersProvider = new BrowserProvider(window.ethereum);
    setState(prev => ({ ...prev, provider: ethersProvider }));
  }, []);

  // 恢复上次连接
  useEffect(() => {
    const savedAddress = localStorage.getItem('walletAddress');
    if (savedAddress && state.provider) {
      setState(prev => ({ ...prev, address: savedAddress }));
    }
  }, [state.provider]);

  // 连接钱包
  const connect = useCallback(async () => {
    if (!state.provider) {
      setState(prev => ({ ...prev, error: 'Provider未初始化' }));
      return;
    }

    setState(prev => ({ ...prev, isConnecting: true, error: '' }));

    try {
      const accounts = await state.provider.send('eth_requestAccounts', []);
      if (accounts.length === 0) {
        throw new Error('没有获取到账户');
      }

      const userAddress = accounts[0];
      const userSigner = await state.provider.getSigner();
      const network = await state.provider.getNetwork();
      const userChainId = Number(network.chainId);

      localStorage.setItem('walletAddress', userAddress);
      localStorage.setItem('walletChainId', userChainId.toString());

      setState(prev => ({
        ...prev,
        address: userAddress,
        signer: userSigner,
        chainId: userChainId,
        isConnecting: false,
      }));
    } catch (err: any) {
      let errorMsg = '连接钱包失败';
      if (err.code === 4001) errorMsg = '用户拒绝了连接请求';
      else if (err.code === -32002) errorMsg = '请先处理MetaMask中的其他请求';
      else if (err.message) errorMsg = err.message;

      setState(prev => ({ ...prev, error: errorMsg, isConnecting: false }));
    }
  }, [state.provider]);

  // 断开连接
  const disconnect = useCallback(() => {
    localStorage.removeItem('walletAddress');
    localStorage.removeItem('walletChainId');
    localStorage.removeItem('authToken');
    setState(prev => ({
      ...prev,
      address: '',
      signer: null,
      chainId: 0,
      error: '',
    }));
  }, []);

  // 监听事件
  useEffect(() => {
    if (!window.ethereum) return;

    const handleAccountsChanged = (accounts: string[]) => {
      if (accounts.length === 0) {
        disconnect();
      } else {
        setState(prev => ({ ...prev, address: accounts[0] }));
        localStorage.setItem('walletAddress', accounts[0]);
      }
    };

    const handleChainChanged = (chainIdHex: string) => {
      const newChainId = parseInt(chainIdHex, 16);
      setState(prev => ({ ...prev, chainId: newChainId }));
      localStorage.setItem('walletChainId', newChainId.toString());
      // 重新获取signer
      if (state.provider) {
        state.provider.getSigner().then(signer => {
          setState(prev => ({ ...prev, signer }));
        });
      }
    };

    window.ethereum.on('accountsChanged', handleAccountsChanged);
    window.ethereum.on('chainChanged', handleChainChanged);

    return () => {
      window.ethereum.removeListener('accountsChanged', handleAccountsChanged);
      window.ethereum.removeListener('chainChanged', handleChainChanged);
    };
  }, [state.provider, disconnect]);

  // 签名消息
  const signMessage = useCallback(async (message: string): Promise<string> => {
    if (!state.signer) {
      throw new Error('请先连接钱包');
    }
    try {
      return await state.signer.signMessage(message);
    } catch (err: any) {
      if (err.code === 4001) throw new Error('用户取消了签名');
      throw new Error('签名失败: ' + err.message);
    }
  }, [state.signer]);

  return {
    ...state,
    connect,
    disconnect,
    signMessage,
  };
}

使用示例:

// App.tsx
import { useWallet } from './hooks/useWallet';

function App() {
  const { address, chainId, error, isConnecting, connect, disconnect, signMessage } = useWallet();

  const handleLogin = async () => {
    try {
      // 假设后端返回nonce
      const signature = await signMessage('登录nonce: 123456');
      // 发送signature到后端验证
      console.log('签名结果:', signature);
    } catch (err: any) {
      console.error(err.message);
    }
  };

  return (
    <div>
      {address ? (
        <div>
          <p>已连接: {address.slice(0, 6)}...{address.slice(-4)}</p>
          <p>链ID: {chainId}</p>
          <button onClick={handleLogin}>签名登录</button>
          <button onClick={disconnect}>断开连接</button>
        </div>
      ) : (
        <button onClick={connect} disabled={isConnecting}>
          {isConnecting ? '连接中...' : '连接钱包'}
        </button>
      )}
      {error && <p style={{ color: 'red' }}>{error}</p>}
    </div>
  );
}

踩坑记录:我实际遇到的4个问题

1. ethers.getDefaultProvider() 在浏览器端报错

  • 报错信息:Error: network error: The method eth_getBlockByNumber does not exist/is not available
  • 原因:getDefaultProvider() 会尝试连接以太坊节点,但在浏览器端,应该使用MetaMask注入的provider。
  • 解决:统一使用 new BrowserProvider(window.ethereum)

2. 用户拒绝连接后,再次点击连接按钮无反应

  • 现象:用户第一次点击"连接钱包"时取消了MetaMask弹窗,再次点击按钮,弹窗不出现了。
  • 原因:MetaMask检测到已有挂起的请求,返回错误码-32002
  • 解决:在catch中处理这个错误,提示用户"请先处理MetaMask中的其他请求",并建议用户刷新页面。

3. 签名时MetaMask弹窗不显示消息内容

  • 现象:调用signer.signMessage()时,MetaMask弹窗只显示"签名请求",没有显示具体的消息内容。
  • 原因:消息是纯字符串,没有格式化为可读的EIP-712类型数据。
  • 解决:对于简单登录,可以使用personal_sign方法,或者确保消息中包含可读的上下文(如"登录XXX")。

4. 链切换后,signer对象失效

  • 现象:用户在MetaMask中切换了网络,但调用signer.getAddress()时返回了旧地址。
  • 原因:signer是在连接时创建的,链变化后需要重新获取signer。
  • 解决:在chainChanged事件监听中,重新调用provider.getSigner()

小结

连接MetaMask实现钱包登录,核心就三点:正确处理用户授权、严格管理状态变化、做好异常处理。不要被"一行代码"的错觉迷惑,真正的坑都在细节里——比如错误码、链ID格式、事件监听的生命周期。

如果你继续深入,可以研究一下:

  • 使用wagmiRainbowKit等库简化钱包连接
  • 实现多链支持,让用户在不同链之间切换
  • 集成EIP-712类型数据签名,提升用户体验

希望这篇文章能帮你少踩一些坑。如果你在实现过程中遇到了其他问题,欢迎在评论区交流。

❌
❌